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 28a28e2..952ed4d 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 @@ -6,6 +6,8 @@ import com.swoosh.microblog.data.model.PageWrapper import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostsResponse +import com.swoosh.microblog.data.model.TagsResponse +import com.swoosh.microblog.data.model.TagWrapper import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.Response @@ -84,6 +86,28 @@ interface GhostApiService { @DELETE("ghost/api/admin/pages/{id}/") suspend fun deletePage(@Path("id") id: String): Response + // --- Tags --- + + @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/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) + } + } +} 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 08963fa..5c92c4b 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 @@ -49,6 +49,7 @@ import com.swoosh.microblog.data.AccountManager import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.GhostTagFull import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.ui.animation.SwooshMotion import kotlinx.coroutines.delay @@ -360,49 +361,16 @@ fun ComposerScreen( } ) - // Extracted tags preview chips - AnimatedVisibility( - visible = state.extractedTags.isNotEmpty(), - enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()), - exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy()) - ) { - Column { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - Icons.Default.Tag, - contentDescription = "Tags", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - @OptIn(ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - state.extractedTags.forEach { tag -> - SuggestionChip( - onClick = {}, - label = { - Text( - "#$tag", - style = MaterialTheme.typography.labelSmall - ) - }, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - border = null - ) - } - } - } - } - } + // Tags section: input + suggestions + chips + Spacer(modifier = Modifier.height(12.dp)) + TagsSection( + tagInput = state.tagInput, + onTagInputChange = viewModel::updateTagInput, + tagSuggestions = state.tagSuggestions, + extractedTags = state.extractedTags, + onAddTag = viewModel::addTag, + onRemoveTag = viewModel::removeTag + ) Spacer(modifier = Modifier.height(12.dp)) @@ -915,3 +883,128 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans return TransformedText(annotated, OffsetMapping.Identity) } } + +/** + * Tag input section with autocomplete suggestions and tag chips. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TagsSection( + tagInput: String, + onTagInputChange: (String) -> Unit, + tagSuggestions: List, + 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, 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 f7d58d2..a29db61 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 @@ -84,6 +85,7 @@ import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.GhostAccount +import com.swoosh.microblog.data.model.GhostTagFull import com.swoosh.microblog.data.model.PostFilter import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.QueueStatus @@ -110,6 +112,7 @@ fun FeedScreen( val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle() + val popularTags by viewModel.popularTags.collectAsStateWithLifecycle() val listState = rememberLazyListState() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -325,20 +328,17 @@ fun FeedScreen( ) } - // Active tag filter bar - if (state.activeTagFilter != null) { - FilterChip( - onClick = { viewModel.clearTagFilter() }, - label = { Text("#${state.activeTagFilter}") }, - selected = true, - leadingIcon = { - Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) - }, - trailingIcon = { - Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp)) - }, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 4.dp) + // Tag filter chips + AnimatedVisibility( + visible = !isSearchActive && popularTags.isNotEmpty(), + enter = fadeIn(SwooshMotion.quick()) + expandVertically(), + exit = fadeOut(SwooshMotion.quick()) + shrinkVertically() + ) { + TagFilterChipsBar( + tags = popularTags, + activeTagFilter = state.activeTagFilter, + onTagSelected = { viewModel.filterByTag(it) }, + onClearFilter = { viewModel.clearTagFilter() } ) } @@ -795,6 +795,58 @@ fun FilterChipsBar( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TagFilterChipsBar( + tags: List, + activeTagFilter: String?, + onTagSelected: (String) -> Unit, + onClearFilter: () -> Unit +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // "All tags" chip + item { + val isAllSelected = activeTagFilter == null + FilterChip( + selected = isAllSelected, + onClick = { onClearFilter() }, + label = { Text("All tags") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + } + + // Tag chips + items(tags, key = { it.id ?: it.name }) { tag -> + val isSelected = activeTagFilter != null && activeTagFilter.equals(tag.name, ignoreCase = true) + FilterChip( + selected = isSelected, + onClick = { + if (isSelected) onClearFilter() else onTagSelected(tag.name) + }, + label = { + val postCount = tag.count?.posts + if (postCount != null) { + Text("${tag.name} ($postCount)") + } else { + Text(tag.name) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + } + } +} + @Composable fun SortButton( currentSort: SortOrder, @@ -1539,23 +1591,19 @@ fun PostCardContent( } } - // Hashtag tags (bold colored text, not chips) + // Tags display if (post.tags.isNotEmpty()) { Spacer(modifier = Modifier.height(10.dp)) - @OptIn(ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - post.tags.forEach { tag -> - Text( - text = "#$tag", - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable { onTagClick(tag) } - ) + Text( + text = post.tags.joinToString(" \u00B7 ") { "#$it" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { + post.tags.firstOrNull()?.let { onTagClick(it) } } - } + ) } // Queue status 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() } 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 5c90ae6..08ce345 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 @@ -38,6 +38,7 @@ import com.swoosh.microblog.ui.preview.PreviewScreen import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.stats.StatsScreen +import com.swoosh.microblog.ui.tags.TagsScreen import com.swoosh.microblog.ui.theme.ThemeViewModel object Routes { @@ -53,6 +54,7 @@ object Routes { const val PAGES = "pages" const val MEMBERS = "members" const val MEMBER_DETAIL = "member_detail" + const val TAGS = "tags" } data class BottomNavItem( @@ -266,6 +268,9 @@ fun SwooshNavGraph( }, onNavigateToPages = { navController.navigate(Routes.PAGES) + }, + onNavigateToTags = { + navController.navigate(Routes.TAGS) } ) } @@ -284,6 +289,18 @@ fun SwooshNavGraph( ) } + composable( + Routes.TAGS, + enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(150)) }, + popEnterTransition = { fadeIn(tween(200)) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) } + ) { + TagsScreen( + onBack = { navController.popBackStack() } + ) + } + composable( Routes.PREVIEW, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) }, 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 36ebe5f..765aed2 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 @@ -13,9 +13,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* @@ -45,7 +47,8 @@ fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, themeViewModel: ThemeViewModel? = null, - onNavigateToPages: () -> Unit = {} + onNavigateToPages: () -> Unit = {}, + onNavigateToTags: () -> Unit = {} ) { val context = LocalContext.current val accountManager = remember { AccountManager(context) } @@ -248,6 +251,48 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) } + // --- Content Management section --- + Text("Content", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedCard( + onClick = onNavigateToTags, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Tag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Tags", + style = MaterialTheme.typography.bodyLarge + ) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = "Navigate to tags", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + // --- Current Account section --- Text("Current Account", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) 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 296208b..5168cb2 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 @@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.stats import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article @@ -13,10 +16,15 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.swoosh.microblog.data.model.GhostTagFull +import com.swoosh.microblog.ui.tags.parseHexColor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -146,6 +154,57 @@ fun StatsScreen( Spacer(modifier = Modifier.height(8.dp)) + // Tags section + if (state.tagStats.isNotEmpty()) { + Text( + "Tags", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Most used tag + if (state.mostUsedTag != null) { + WritingStatRow( + "Most used tag", + "#${state.mostUsedTag!!.name} (${state.mostUsedTag!!.count?.posts ?: 0})" + ) + HorizontalDivider() + } + + // Posts without tags + WritingStatRow( + "Posts without tags", + "${state.postsWithoutTags}" + ) + + HorizontalDivider() + + // Total tags + WritingStatRow( + "Total tags", + "${state.tagStats.size}" + ) + + // Tag progress bars + val maxCount = state.tagStats.maxOfOrNull { it.count?.posts ?: 0 } ?: 1 + + state.tagStats.take(10).forEach { tag -> + TagProgressBar( + tag = tag, + maxCount = maxCount.coerceAtLeast(1) + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + // Writing stats section Text( "Writing Stats", @@ -267,6 +326,68 @@ private fun formatMrr(cents: Int): String { } } +@Composable +private fun TagProgressBar( + tag: GhostTagFull, + maxCount: Int +) { + val postCount = tag.count?.posts ?: 0 + val progress = postCount.toFloat() / maxCount.toFloat() + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(600), + label = "tagProgress_${tag.name}" + ) + + val barColor = tag.accent_color?.let { parseHexColor(it) } + ?: MaterialTheme.colorScheme.primary + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(barColor) + ) + Text( + text = tag.name, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = "$postCount", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = barColor, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } +} + @Composable private fun StatsCard( modifier: Modifier = Modifier, 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 fbd5456..62154a5 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,10 +4,12 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.GhostTagFull import com.swoosh.microblog.data.model.OverallStats import com.swoosh.microblog.data.repository.MemberRepository import com.swoosh.microblog.data.repository.MemberStats import com.swoosh.microblog.data.repository.PostRepository +import com.swoosh.microblog.data.repository.TagRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +20,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { private val repository = PostRepository(application) private val memberRepository = MemberRepository(application) + private val tagRepository = TagRepository(application) private val _uiState = MutableStateFlow(StatsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -53,6 +56,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { linkTitle = null, linkDescription = null, linkImageUrl = null, + tags = ghost.tags?.map { it.name } ?: emptyList(), status = ghost.status ?: "draft", publishedAt = ghost.published_at, createdAt = ghost.created_at, @@ -87,7 +91,33 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { null } - _uiState.update { it.copy(stats = stats, memberStats = memberStats, isLoading = false) } + // Fetch tag stats + val tagStats = try { + tagRepository.fetchTags().getOrNull() + ?.sortedByDescending { it.count?.posts ?: 0 } + ?: emptyList() + } catch (e: Exception) { + emptyList() + } + + // Count posts without any tags + val totalPosts = localPosts.size + uniqueRemotePosts.size + val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() } + val postsWithoutTags = totalPosts - postsWithTags + + // Most used tag + val mostUsedTag = tagStats.firstOrNull() + + _uiState.update { + it.copy( + stats = stats, + memberStats = memberStats, + tagStats = tagStats, + mostUsedTag = mostUsedTag, + postsWithoutTags = postsWithoutTags, + isLoading = false + ) + } } catch (e: Exception) { _uiState.update { it.copy(isLoading = false, error = e.message) } } @@ -98,6 +128,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { data class StatsUiState( val stats: OverallStats = OverallStats(), val memberStats: MemberStats? = null, + val tagStats: List = emptyList(), + val mostUsedTag: GhostTagFull? = null, + val postsWithoutTags: Int = 0, val isLoading: Boolean = false, val error: String? = null ) 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) } +} 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) + } +}