mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate hashtag support (resolve conflicts)
This commit is contained in:
commit
91982a66a2
13 changed files with 733 additions and 48 deletions
78
app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt
Normal file
78
app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
/**
|
||||
* Parses #hashtag patterns from post text content.
|
||||
*
|
||||
* Rules:
|
||||
* - Hashtags start with # followed by at least one letter or digit
|
||||
* - Allowed characters: letters, digits, hyphens, underscores
|
||||
* - Hashtags must NOT be inside URLs (http://, https://, or www.)
|
||||
* - Leading # is stripped from the result
|
||||
* - Results are unique (case-preserved, deduped case-insensitively)
|
||||
* - Trailing hyphens/underscores are trimmed from tag names
|
||||
*/
|
||||
object HashtagParser {
|
||||
|
||||
// Matches URLs so we can skip hashtags that appear inside them
|
||||
private val URL_PATTERN = Regex(
|
||||
"""(?:https?://|www\.)\S+""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
// Matches a hashtag: # followed by at least one word char (letter/digit),
|
||||
// then optionally more word chars or hyphens
|
||||
private val HASHTAG_PATTERN = Regex(
|
||||
"""#([\w][\w-]*)""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts unique hashtag names from text content.
|
||||
* Returns tag names without the # prefix.
|
||||
*/
|
||||
fun parse(text: String): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
// Find all URL ranges so we can exclude hashtags inside URLs
|
||||
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
|
||||
|
||||
val seen = mutableSetOf<String>()
|
||||
val result = mutableListOf<String>()
|
||||
|
||||
for (match in HASHTAG_PATTERN.findAll(text)) {
|
||||
// Skip if this hashtag is inside a URL
|
||||
if (urlRanges.any { urlRange -> match.range.first in urlRange }) {
|
||||
continue
|
||||
}
|
||||
|
||||
val tagName = match.groupValues[1].trimEnd('-', '_')
|
||||
|
||||
// Must have at least one character after trimming
|
||||
if (tagName.isEmpty()) continue
|
||||
|
||||
// Deduplicate case-insensitively but preserve original casing
|
||||
val lowerTag = tagName.lowercase()
|
||||
if (lowerTag !in seen) {
|
||||
seen.add(lowerTag)
|
||||
result.add(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ranges of hashtags in the text for highlighting.
|
||||
* Each range covers the full hashtag including the # prefix.
|
||||
*/
|
||||
fun findHashtagRanges(text: String): List<IntRange> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
|
||||
|
||||
return HASHTAG_PATTERN.findAll(text)
|
||||
.filter { match -> urlRanges.none { urlRange -> match.range.first in urlRange } }
|
||||
.map { it.range }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ interface GhostApiService {
|
|||
suspend fun getPosts(
|
||||
@Query("limit") limit: Int = 15,
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("include") include: String = "authors",
|
||||
@Query("include") include: String = "authors,tags",
|
||||
@Query("formats") formats: String = "html,plaintext,mobiledoc",
|
||||
@Query("order") order: String = "created_at desc",
|
||||
@Query("filter") filter: String? = null
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@ data class GhostPost(
|
|||
val custom_excerpt: String? = null,
|
||||
val visibility: String? = "public",
|
||||
val authors: List<Author>? = null,
|
||||
val reading_time: Int? = null
|
||||
val reading_time: Int? = null,
|
||||
val tags: List<GhostTag>? = null
|
||||
)
|
||||
|
||||
data class GhostTag(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val slug: String? = null
|
||||
)
|
||||
|
||||
data class Author(
|
||||
|
|
@ -74,6 +81,7 @@ data class LocalPost(
|
|||
val linkImageUrl: String? = null,
|
||||
val imageAlt: String? = null,
|
||||
val scheduledAt: String? = null,
|
||||
val tags: String = "[]",
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE
|
||||
|
|
@ -109,6 +117,7 @@ data class FeedPost(
|
|||
val linkTitle: String?,
|
||||
val linkDescription: String?,
|
||||
val linkImageUrl: String?,
|
||||
val tags: List<String> = emptyList(),
|
||||
val status: String,
|
||||
val featured: Boolean = false,
|
||||
val publishedAt: String?,
|
||||
|
|
|
|||
|
|
@ -36,15 +36,22 @@ class PostRepository(private val context: Context) {
|
|||
page: Int = 1,
|
||||
limit: Int = 15,
|
||||
filter: PostFilter = PostFilter.ALL,
|
||||
sortOrder: SortOrder = SortOrder.NEWEST
|
||||
sortOrder: SortOrder = SortOrder.NEWEST,
|
||||
tagFilter: String? = null
|
||||
): Result<PostsResponse> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Combine status filter and tag filter
|
||||
val filterParts = mutableListOf<String>()
|
||||
filter.ghostFilter?.let { filterParts.add(it) }
|
||||
tagFilter?.let { filterParts.add("tag:$it") }
|
||||
val combinedFilter = filterParts.takeIf { it.isNotEmpty() }?.joinToString("+")
|
||||
|
||||
val response = getApi().getPosts(
|
||||
limit = limit,
|
||||
page = page,
|
||||
order = sortOrder.ghostOrder,
|
||||
filter = filter.ghostFilter
|
||||
filter = combinedFilter
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!)
|
||||
|
|
|
|||
|
|
@ -16,15 +16,22 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.PostStats
|
||||
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
|
||||
|
|
@ -96,6 +103,12 @@ fun ComposerScreen(
|
|||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Hashtag visual transformation for edit mode text field
|
||||
val hashtagColor = MaterialTheme.colorScheme.primary
|
||||
val hashtagTransformation = remember(hashtagColor) {
|
||||
HashtagVisualTransformation(hashtagColor)
|
||||
}
|
||||
|
||||
// Edit / Preview segmented button row
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
|
|
@ -168,7 +181,7 @@ fun ComposerScreen(
|
|||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Text field with character counter
|
||||
// Text field with character counter and hashtag highlighting
|
||||
OutlinedTextField(
|
||||
value = state.text,
|
||||
onValueChange = viewModel::updateText,
|
||||
|
|
@ -176,6 +189,7 @@ fun ComposerScreen(
|
|||
.fillMaxWidth()
|
||||
.heightIn(min = 150.dp),
|
||||
placeholder = { Text("What's on your mind?") },
|
||||
visualTransformation = hashtagTransformation,
|
||||
supportingText = {
|
||||
val charCount = state.text.length
|
||||
val statsText = PostStats.formatComposerStats(state.text)
|
||||
|
|
@ -192,6 +206,44 @@ fun ComposerScreen(
|
|||
}
|
||||
)
|
||||
|
||||
// Extracted tags preview chips
|
||||
if (state.extractedTags.isNotEmpty()) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Attachment buttons row
|
||||
|
|
@ -550,3 +602,28 @@ fun ScheduleDateTimePicker(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VisualTransformation that highlights #hashtags in a different color.
|
||||
*/
|
||||
class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTransformation {
|
||||
override fun filter(text: androidx.compose.ui.text.AnnotatedString): TransformedText {
|
||||
val ranges = HashtagParser.findHashtagRanges(text.text)
|
||||
if (ranges.isEmpty()) {
|
||||
return TransformedText(text, OffsetMapping.Identity)
|
||||
}
|
||||
|
||||
val annotated = buildAnnotatedString {
|
||||
append(text.text)
|
||||
for (range in ranges) {
|
||||
val safeEnd = minOf(range.last + 1, text.text.length)
|
||||
addStyle(
|
||||
SpanStyle(color = hashtagColor),
|
||||
start = range.first,
|
||||
end = safeEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
return TransformedText(annotated, OffsetMapping.Identity)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import android.app.Application
|
|||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.worker.PostUploadWorker
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -48,13 +51,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
imageUrl = post.linkImageUrl
|
||||
) else null,
|
||||
featured = post.featured,
|
||||
extractedTags = HashtagParser.parse(post.textContent),
|
||||
isEditing = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateText(text: String) {
|
||||
_uiState.update { it.copy(text = text) }
|
||||
val extractedTags = HashtagParser.parse(text)
|
||||
_uiState.update { it.copy(text = text, extractedTags = extractedTags) }
|
||||
if (_uiState.value.isPreviewMode) {
|
||||
debouncedPreviewUpdate()
|
||||
}
|
||||
|
|
@ -161,6 +166,8 @@ 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)
|
||||
|
||||
val altText = state.imageAlt.ifBlank { null }
|
||||
|
||||
|
|
@ -180,6 +187,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
linkDescription = state.linkPreview?.description,
|
||||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
|
|
@ -212,6 +220,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
featureImage,
|
||||
altText
|
||||
)
|
||||
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = title,
|
||||
|
|
@ -221,7 +230,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
feature_image = featureImage,
|
||||
feature_image_alt = altText,
|
||||
published_at = state.scheduledAt,
|
||||
visibility = "public"
|
||||
visibility = "public",
|
||||
tags = ghostTags.ifEmpty { null }
|
||||
)
|
||||
|
||||
val result = if (editingGhostId != null) {
|
||||
|
|
@ -252,6 +262,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
linkDescription = state.linkPreview?.description,
|
||||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = offlineQueueStatus
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
|
|
@ -283,6 +294,7 @@ data class ComposerUiState(
|
|||
val isLoadingLink: Boolean = false,
|
||||
val scheduledAt: String? = null,
|
||||
val featured: Boolean = false,
|
||||
val extractedTags: List<String> = emptyList(),
|
||||
val isSubmitting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ import com.swoosh.microblog.ui.feed.StatusBadge
|
|||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
post: FeedPost,
|
||||
|
|
@ -202,6 +202,29 @@ fun DetailScreen(
|
|||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
// Tags
|
||||
if (post.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
post.tags.forEach { tag ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text("#$tag", style = MaterialTheme.typography.labelMedium)
|
||||
},
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
border = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full image
|
||||
if (post.imageUrl != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
|
|||
|
|
@ -23,20 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
|
|
@ -225,6 +211,23 @@ 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)
|
||||
)
|
||||
}
|
||||
|
||||
// Show recent searches when search is active but query is empty
|
||||
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
|
||||
RecentSearchesList(
|
||||
|
|
@ -415,6 +418,7 @@ fun FeedScreen(
|
|||
onEdit = { onEditPost(post) },
|
||||
onDelete = { postPendingDelete = post },
|
||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
|
@ -455,6 +459,7 @@ fun FeedScreen(
|
|||
onEdit = { onEditPost(post) },
|
||||
onDelete = { postPendingDelete = post },
|
||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
|
@ -663,6 +668,7 @@ fun SwipeablePostCard(
|
|||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePin: () -> Unit = {},
|
||||
onTagClick: (String) -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState? = null
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
|
|
@ -713,6 +719,7 @@ fun SwipeablePostCard(
|
|||
onEdit = onEdit,
|
||||
onDelete = onDelete,
|
||||
onTogglePin = onTogglePin,
|
||||
onTagClick = onTagClick,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
|
@ -790,7 +797,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SearchTopBar(
|
||||
query: String,
|
||||
|
|
@ -917,6 +924,7 @@ fun PostCardContent(
|
|||
onEdit: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onTogglePin: () -> Unit = {},
|
||||
onTagClick: (String) -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
highlightQuery: String? = null
|
||||
) {
|
||||
|
|
@ -1060,6 +1068,30 @@ fun PostCardContent(
|
|||
}
|
||||
}
|
||||
|
||||
// Hashtag chips
|
||||
if (post.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
post.tags.forEach { tag ->
|
||||
SuggestionChip(
|
||||
onClick = { onTagClick(tag) },
|
||||
label = {
|
||||
Text("#$tag", style = MaterialTheme.typography.labelSmall)
|
||||
},
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
border = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link preview
|
||||
if (post.linkUrl != null && post.linkTitle != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import androidx.lifecycle.AndroidViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.FeedPreferences
|
||||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -205,8 +208,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
val filter = _activeFilter.value
|
||||
val sort = _sortOrder.value
|
||||
val tagFilter = _uiState.value.activeTagFilter
|
||||
|
||||
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort).fold(
|
||||
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
|
||||
onSuccess = { response ->
|
||||
remotePosts = response.posts.map { it.toFeedPost() }
|
||||
hasMorePages = response.meta?.pagination?.next != null
|
||||
|
|
@ -221,6 +225,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
}
|
||||
|
||||
fun filterByTag(tag: String) {
|
||||
_uiState.update { it.copy(activeTagFilter = tag) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun clearTagFilter() {
|
||||
_uiState.update { it.copy(activeTagFilter = null) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun classifyError(e: Throwable): Pair<String, Boolean> {
|
||||
val cause = e.cause ?: e
|
||||
return when {
|
||||
|
|
@ -249,8 +263,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
val filter = _activeFilter.value
|
||||
val sort = _sortOrder.value
|
||||
val tagFilter = _uiState.value.activeTagFilter
|
||||
|
||||
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort).fold(
|
||||
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
|
||||
onSuccess = { response ->
|
||||
val newPosts = response.posts.map { it.toFeedPost() }
|
||||
remotePosts = remotePosts + newPosts
|
||||
|
|
@ -385,8 +400,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
|
||||
val tagFilter = _uiState.value.activeTagFilter
|
||||
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
|
||||
val allPosts = queued + remotePosts
|
||||
// Filter local posts by tag if a tag filter is active
|
||||
val filteredQueued = if (tagFilter != null) {
|
||||
queued.filter { post ->
|
||||
post.tags.any { it.equals(tagFilter, ignoreCase = true) }
|
||||
}
|
||||
} else queued
|
||||
val allPosts = filteredQueued + remotePosts
|
||||
// Sort: featured/pinned posts first, then chronological
|
||||
val sorted = allPosts.sortedWith(
|
||||
compareByDescending<FeedPost> { it.featured }
|
||||
|
|
@ -409,6 +431,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
tags = tags?.map { it.name } ?: emptyList(),
|
||||
status = status ?: "draft",
|
||||
featured = featured ?: false,
|
||||
publishedAt = published_at,
|
||||
|
|
@ -417,26 +440,34 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
isLocal = false
|
||||
)
|
||||
|
||||
private fun LocalPost.toFeedPost(): FeedPost = FeedPost(
|
||||
localId = localId,
|
||||
ghostId = ghostId,
|
||||
title = title,
|
||||
textContent = content,
|
||||
htmlContent = htmlContent,
|
||||
imageUrl = uploadedImageUrl ?: imageUri,
|
||||
imageAlt = imageAlt,
|
||||
linkUrl = linkUrl,
|
||||
linkTitle = linkTitle,
|
||||
linkDescription = linkDescription,
|
||||
linkImageUrl = linkImageUrl,
|
||||
status = status.name.lowercase(),
|
||||
featured = featured,
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
isLocal = true,
|
||||
queueStatus = queueStatus
|
||||
)
|
||||
private fun LocalPost.toFeedPost(): FeedPost {
|
||||
val tagNames: List<String> = try {
|
||||
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
return FeedPost(
|
||||
localId = localId,
|
||||
ghostId = ghostId,
|
||||
title = title,
|
||||
textContent = content,
|
||||
htmlContent = htmlContent,
|
||||
imageUrl = uploadedImageUrl ?: imageUri,
|
||||
imageAlt = imageAlt,
|
||||
linkUrl = linkUrl,
|
||||
linkTitle = linkTitle,
|
||||
linkDescription = linkDescription,
|
||||
linkImageUrl = linkImageUrl,
|
||||
tags = tagNames,
|
||||
status = status.name.lowercase(),
|
||||
featured = featured,
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
isLocal = true,
|
||||
queueStatus = queueStatus
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
|
@ -468,7 +499,8 @@ data class FeedUiState(
|
|||
val isLoadingMore: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isConnectionError: Boolean = false,
|
||||
val snackbarMessage: String? = null
|
||||
val snackbarMessage: String? = null,
|
||||
val activeTagFilter: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import android.net.Uri
|
|||
import androidx.work.*
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.model.GhostPost
|
||||
import com.swoosh.microblog.data.model.GhostTag
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PostUploadWorker(
|
||||
|
|
@ -42,6 +45,14 @@ class PostUploadWorker(
|
|||
featureImage, post.imageAlt
|
||||
)
|
||||
|
||||
// Parse tags from JSON stored in LocalPost
|
||||
val tagNames: List<String> = try {
|
||||
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
val ghostTags = tagNames.map { GhostTag(name = it) }
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = post.title,
|
||||
mobiledoc = mobiledoc,
|
||||
|
|
@ -54,7 +65,8 @@ class PostUploadWorker(
|
|||
feature_image = featureImage,
|
||||
feature_image_alt = post.imageAlt,
|
||||
published_at = post.scheduledAt,
|
||||
visibility = "public"
|
||||
visibility = "public",
|
||||
tags = ghostTags.ifEmpty { null }
|
||||
)
|
||||
|
||||
val result = if (post.ghostId != null) {
|
||||
|
|
|
|||
218
app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt
Normal file
218
app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class HashtagParserTest {
|
||||
|
||||
// --- Basic parsing ---
|
||||
|
||||
@Test
|
||||
fun `parse extracts single hashtag`() {
|
||||
val tags = HashtagParser.parse("Hello #world")
|
||||
assertEquals(listOf("world"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse extracts multiple hashtags`() {
|
||||
val tags = HashtagParser.parse("Hello #world #kotlin #android")
|
||||
assertEquals(listOf("world", "kotlin", "android"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse returns empty list for text without hashtags`() {
|
||||
val tags = HashtagParser.parse("Hello world, no tags here!")
|
||||
assertTrue(tags.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse returns empty list for blank text`() {
|
||||
val tags = HashtagParser.parse("")
|
||||
assertTrue(tags.isEmpty())
|
||||
val tags2 = HashtagParser.parse(" ")
|
||||
assertTrue(tags2.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse strips hash prefix from results`() {
|
||||
val tags = HashtagParser.parse("#kotlin")
|
||||
assertEquals("kotlin", tags.first())
|
||||
assertFalse(tags.first().startsWith("#"))
|
||||
}
|
||||
|
||||
// --- Position handling ---
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtag at start of text`() {
|
||||
val tags = HashtagParser.parse("#hello this is a post")
|
||||
assertEquals(listOf("hello"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtag at end of text`() {
|
||||
val tags = HashtagParser.parse("This is a post #hello")
|
||||
assertEquals(listOf("hello"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtag in middle of text`() {
|
||||
val tags = HashtagParser.parse("This is a #hello post")
|
||||
assertEquals(listOf("hello"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtags on multiple lines`() {
|
||||
val tags = HashtagParser.parse("Line one #tag1\nLine two #tag2\nLine three #tag3")
|
||||
assertEquals(listOf("tag1", "tag2", "tag3"), tags)
|
||||
}
|
||||
|
||||
// --- Special characters ---
|
||||
|
||||
@Test
|
||||
fun `parse handles CamelCase hashtags`() {
|
||||
val tags = HashtagParser.parse("Hello #CamelCase and #helloWorld")
|
||||
assertEquals(listOf("CamelCase", "helloWorld"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hyphenated hashtags`() {
|
||||
val tags = HashtagParser.parse("Check out #hello-world")
|
||||
assertEquals(listOf("hello-world"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles underscored hashtags`() {
|
||||
val tags = HashtagParser.parse("Check out #hello_world")
|
||||
assertEquals(listOf("hello_world"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtags with digits`() {
|
||||
val tags = HashtagParser.parse("Check out #tag123 and #2024goals")
|
||||
assertEquals(listOf("tag123", "2024goals"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse trims trailing hyphens from tag names`() {
|
||||
val tags = HashtagParser.parse("Check #hello- there")
|
||||
assertEquals(listOf("hello"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse trims trailing underscores from tag names`() {
|
||||
val tags = HashtagParser.parse("Check #hello_ there")
|
||||
assertEquals(listOf("hello"), tags)
|
||||
}
|
||||
|
||||
// --- Deduplication ---
|
||||
|
||||
@Test
|
||||
fun `parse deduplicates tags case-insensitively`() {
|
||||
val tags = HashtagParser.parse("#Hello #hello #HELLO")
|
||||
assertEquals(1, tags.size)
|
||||
assertEquals("Hello", tags.first()) // Preserves first occurrence casing
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse preserves order of unique tags`() {
|
||||
val tags = HashtagParser.parse("#beta #alpha #gamma")
|
||||
assertEquals(listOf("beta", "alpha", "gamma"), tags)
|
||||
}
|
||||
|
||||
// --- URL exclusion ---
|
||||
|
||||
@Test
|
||||
fun `parse ignores hashtags inside URLs with https`() {
|
||||
val tags = HashtagParser.parse("Visit https://example.com/page#section and also #realtag")
|
||||
assertEquals(listOf("realtag"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse ignores hashtags inside URLs with http`() {
|
||||
val tags = HashtagParser.parse("Visit http://example.com/path#anchor and #realtag")
|
||||
assertEquals(listOf("realtag"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse ignores hashtags inside www URLs`() {
|
||||
val tags = HashtagParser.parse("Visit www.example.com/path#anchor for more #info")
|
||||
assertEquals(listOf("info"), tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles URL followed by real hashtag`() {
|
||||
val tags = HashtagParser.parse("https://example.com #kotlin")
|
||||
assertEquals(listOf("kotlin"), tags)
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
@Test
|
||||
fun `parse ignores lone hash symbol`() {
|
||||
val tags = HashtagParser.parse("Use # wisely")
|
||||
assertTrue(tags.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles adjacent hashtags`() {
|
||||
val tags = HashtagParser.parse("#one#two#three")
|
||||
// #one is parsed, then #two and #three appear inside #one's text or as separate matches
|
||||
assertTrue(tags.contains("one"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtag with punctuation after it`() {
|
||||
val tags = HashtagParser.parse("Check #kotlin, it's great! And #android.")
|
||||
assertTrue(tags.contains("kotlin"))
|
||||
assertTrue(tags.contains("android"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles hashtag in parentheses`() {
|
||||
val tags = HashtagParser.parse("Languages (#kotlin) are great")
|
||||
assertTrue(tags.contains("kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse handles real-world microblog post`() {
|
||||
val text = "Just shipped a new feature for Swoosh! #android #ghostcms #opensource\n\nCheck it out at https://github.com/example/swoosh#readme"
|
||||
val tags = HashtagParser.parse(text)
|
||||
assertEquals(3, tags.size)
|
||||
assertTrue(tags.contains("android"))
|
||||
assertTrue(tags.contains("ghostcms"))
|
||||
assertTrue(tags.contains("opensource"))
|
||||
// Should NOT include 'readme' from the URL
|
||||
assertFalse(tags.contains("readme"))
|
||||
}
|
||||
|
||||
// --- findHashtagRanges ---
|
||||
|
||||
@Test
|
||||
fun `findHashtagRanges returns correct ranges`() {
|
||||
val text = "Hello #world and #kotlin"
|
||||
val ranges = HashtagParser.findHashtagRanges(text)
|
||||
assertEquals(2, ranges.size)
|
||||
assertEquals("#world", text.substring(ranges[0]))
|
||||
assertEquals("#kotlin", text.substring(ranges[1]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findHashtagRanges returns empty for no hashtags`() {
|
||||
val ranges = HashtagParser.findHashtagRanges("No hashtags here")
|
||||
assertTrue(ranges.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findHashtagRanges excludes URL hashtags`() {
|
||||
val text = "Visit https://example.com#section and #realtag"
|
||||
val ranges = HashtagParser.findHashtagRanges(text)
|
||||
assertEquals(1, ranges.size)
|
||||
assertEquals("#realtag", text.substring(ranges[0]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findHashtagRanges returns empty for blank text`() {
|
||||
val ranges = HashtagParser.findHashtagRanges("")
|
||||
assertTrue(ranges.isEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GhostTagTest {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
@Test
|
||||
fun `GhostTag serializes to JSON correctly`() {
|
||||
val tag = GhostTag(name = "kotlin")
|
||||
val json = gson.toJson(tag)
|
||||
assertTrue(json.contains("\"name\":\"kotlin\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTag serializes with all fields`() {
|
||||
val tag = GhostTag(id = "tag123", name = "kotlin", slug = "kotlin")
|
||||
val json = gson.toJson(tag)
|
||||
assertTrue(json.contains("\"id\":\"tag123\""))
|
||||
assertTrue(json.contains("\"name\":\"kotlin\""))
|
||||
assertTrue(json.contains("\"slug\":\"kotlin\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTag deserializes from JSON`() {
|
||||
val json = """{"id":"abc","name":"android","slug":"android"}"""
|
||||
val tag = gson.fromJson(json, GhostTag::class.java)
|
||||
assertEquals("abc", tag.id)
|
||||
assertEquals("android", tag.name)
|
||||
assertEquals("android", tag.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTag deserializes with only name`() {
|
||||
val json = """{"name":"test-tag"}"""
|
||||
val tag = gson.fromJson(json, GhostTag::class.java)
|
||||
assertNull(tag.id)
|
||||
assertEquals("test-tag", tag.name)
|
||||
assertNull(tag.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTag list serializes for API payload`() {
|
||||
val tags = listOf(
|
||||
GhostTag(name = "kotlin"),
|
||||
GhostTag(name = "android"),
|
||||
GhostTag(name = "ghost-cms")
|
||||
)
|
||||
val json = gson.toJson(tags)
|
||||
assertTrue(json.contains("kotlin"))
|
||||
assertTrue(json.contains("android"))
|
||||
assertTrue(json.contains("ghost-cms"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTag list deserializes from API response`() {
|
||||
val json = """[{"id":"1","name":"kotlin","slug":"kotlin"},{"id":"2","name":"android","slug":"android"}]"""
|
||||
val type = object : TypeToken<List<GhostTag>>() {}.type
|
||||
val tags: List<GhostTag> = gson.fromJson(json, type)
|
||||
assertEquals(2, tags.size)
|
||||
assertEquals("kotlin", tags[0].name)
|
||||
assertEquals("android", tags[1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost with tags serializes correctly for API`() {
|
||||
val post = GhostPost(
|
||||
title = "Test Post",
|
||||
status = "published",
|
||||
tags = listOf(GhostTag(name = "kotlin"), GhostTag(name = "android"))
|
||||
)
|
||||
val wrapper = PostWrapper(listOf(post))
|
||||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"tags\""))
|
||||
assertTrue(json.contains("\"kotlin\""))
|
||||
assertTrue(json.contains("\"android\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost with null tags serializes without tags`() {
|
||||
val post = GhostPost(title = "Test Post", tags = null)
|
||||
val json = gson.toJson(post)
|
||||
// Gson does not serialize null fields by default
|
||||
assertFalse(json.contains("\"tags\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost with empty tags list serializes with empty array`() {
|
||||
val post = GhostPost(title = "Test Post", tags = emptyList())
|
||||
val json = gson.toJson(post)
|
||||
assertTrue(json.contains("\"tags\":[]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost deserializes tags from API response`() {
|
||||
val json = """{
|
||||
"id": "post1",
|
||||
"title": "Hello",
|
||||
"tags": [{"id":"t1","name":"kotlin","slug":"kotlin"},{"id":"t2","name":"android","slug":"android"}]
|
||||
}"""
|
||||
val post = gson.fromJson(json, GhostPost::class.java)
|
||||
assertNotNull(post.tags)
|
||||
assertEquals(2, post.tags!!.size)
|
||||
assertEquals("kotlin", post.tags!![0].name)
|
||||
assertEquals("android", post.tags!![1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost without tags field deserializes with null tags`() {
|
||||
val json = """{"id":"post1","title":"Hello"}"""
|
||||
val post = gson.fromJson(json, GhostPost::class.java)
|
||||
assertNull(post.tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Tag names stored as JSON in LocalPost round-trip correctly`() {
|
||||
val tagNames = listOf("kotlin", "android", "ghost-cms")
|
||||
val json = gson.toJson(tagNames)
|
||||
val type = object : TypeToken<List<String>>() {}.type
|
||||
val restored: List<String> = gson.fromJson(json, type)
|
||||
assertEquals(tagNames, restored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty tag list stored as JSON round-trips correctly`() {
|
||||
val tagNames = emptyList<String>()
|
||||
val json = gson.toJson(tagNames)
|
||||
assertEquals("[]", json)
|
||||
val type = object : TypeToken<List<String>>() {}.type
|
||||
val restored: List<String> = gson.fromJson(json, type)
|
||||
assertTrue(restored.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FeedPost default tags is empty list`() {
|
||||
val post = FeedPost(
|
||||
title = "Test",
|
||||
textContent = "Content",
|
||||
htmlContent = null,
|
||||
imageUrl = null,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
status = "published",
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null
|
||||
)
|
||||
assertTrue(post.tags.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FeedPost with tags stores them correctly`() {
|
||||
val post = FeedPost(
|
||||
title = "Test",
|
||||
textContent = "Content #kotlin",
|
||||
htmlContent = null,
|
||||
imageUrl = null,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
tags = listOf("kotlin", "android"),
|
||||
status = "published",
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null
|
||||
)
|
||||
assertEquals(2, post.tags.size)
|
||||
assertEquals("kotlin", post.tags[0])
|
||||
assertEquals("android", post.tags[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LocalPost default tags is empty JSON array`() {
|
||||
val post = LocalPost()
|
||||
assertEquals("[]", post.tags)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue