feat: add full-text search with debounce, history, and highlighted results

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:10 +01:00
parent 74f42fd2f1
commit bbc408d5df
No known key found for this signature in database
6 changed files with 1016 additions and 55 deletions

View file

@ -15,7 +15,8 @@ interface GhostApiService {
@Query("page") page: Int = 1,
@Query("include") include: String = "authors",
@Query("formats") formats: String = "html,plaintext,mobiledoc",
@Query("order") order: String = "created_at desc"
@Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null
): Response<PostsResponse>
@POST("ghost/api/admin/posts/")

View file

@ -31,6 +31,9 @@ interface LocalPostDao {
@Delete
suspend fun deletePost(post: LocalPost)
@Query("SELECT * FROM local_posts WHERE content LIKE '%' || :query || '%' OR title LIKE '%' || :query || '%' ORDER BY createdAt DESC")
suspend fun searchPosts(query: String): List<LocalPost>
@Query("DELETE FROM local_posts WHERE localId = :localId")
suspend fun deleteById(localId: Long)

View file

@ -120,6 +120,76 @@ class PostRepository(private val context: Context) {
return tempFile
}
// --- Search operations ---
suspend fun searchLocalPosts(query: String): List<LocalPost> =
withContext(Dispatchers.IO) {
dao.searchPosts(query)
}
suspend fun searchRemotePosts(query: String): Result<List<GhostPost>> =
withContext(Dispatchers.IO) {
try {
// Ghost Admin API supports plaintext filter
val response = getApi().getPosts(
limit = 50,
filter = "plaintext:~'$query'"
)
if (response.isSuccessful) {
Result.success(response.body()!!.posts)
} else {
// Fallback: fetch posts and filter client-side
searchRemoteClientSide(query)
}
} catch (e: Exception) {
// Fallback: fetch posts and filter client-side
try {
searchRemoteClientSide(query)
} catch (e2: Exception) {
Result.failure(e2)
}
}
}
private suspend fun searchRemoteClientSide(query: String): Result<List<GhostPost>> {
val allPosts = mutableListOf<GhostPost>()
var page = 1
var hasMore = true
while (hasMore && page <= 5) { // Limit to 5 pages for search
val response = getApi().getPosts(limit = 50, page = page)
if (response.isSuccessful) {
val body = response.body()!!
allPosts.addAll(body.posts)
hasMore = body.meta?.pagination?.next != null
page++
} else {
return Result.failure(Exception("API error ${response.code()}"))
}
}
val queryLower = query.lowercase()
val filtered = allPosts.filter { post ->
(post.title?.lowercase()?.contains(queryLower) == true) ||
(post.plaintext?.lowercase()?.contains(queryLower) == true) ||
(post.html?.lowercase()?.contains(queryLower) == true)
}
return Result.success(filtered)
}
suspend fun searchPosts(query: String): Result<List<Pair<List<LocalPost>, List<GhostPost>>>> =
withContext(Dispatchers.IO) {
val localResults = searchLocalPosts(query)
val remoteResult = try {
searchRemotePosts(query)
} catch (e: Exception) {
Result.failure(e)
}
val remotePosts = remoteResult.getOrDefault(emptyList())
Result.success(listOf(localResults to remotePosts))
}
// --- Local operations ---
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()

View file

@ -1,5 +1,8 @@
package com.swoosh.microblog.ui.feed
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -7,10 +10,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -19,9 +20,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@ -38,6 +45,12 @@ fun FeedScreen(
viewModel: FeedViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle()
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val searchResultCount by viewModel.searchResultCount.collectAsStateWithLifecycle()
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Pull-to-refresh
@ -55,28 +68,46 @@ fun FeedScreen(
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && state.posts.isNotEmpty()) {
if (shouldLoadMore && state.posts.isNotEmpty() && !isSearchActive) {
viewModel.loadMore()
}
}
val displayPosts = if (isSearchActive && searchQuery.isNotBlank()) searchResults else state.posts
val focusRequester = remember { FocusRequester() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Swoosh") },
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
if (isSearchActive) {
SearchTopBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
onClose = viewModel::deactivateSearch,
onClear = viewModel::clearSearchQuery,
focusRequester = focusRequester
)
} else {
TopAppBar(
title = { Text("Swoosh") },
actions = {
IconButton(onClick = { viewModel.activateSearch() }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
if (!isSearchActive) {
FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
}
}
) { padding ->
@ -84,9 +115,63 @@ fun FeedScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.pullRefresh(pullRefreshState)
.then(if (!isSearchActive) Modifier.pullRefresh(pullRefreshState) else Modifier)
) {
if (state.posts.isEmpty() && !state.isRefreshing) {
// Show recent searches when search is active but query is empty
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
RecentSearchesList(
recentSearches = recentSearches,
onSearchTap = viewModel::onRecentSearchTap,
onRemove = viewModel::removeRecentSearch
)
} else if (isSearchActive && searchQuery.isNotBlank() && isSearching) {
// Searching indicator
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Searching...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else if (isSearchActive && searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching) {
// No results empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No results found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No posts matching \"$searchQuery\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
} else if (!isSearchActive && displayPosts.isEmpty() && !state.isRefreshing) {
if (state.isConnectionError && state.error != null) {
// Connection error empty state
Column(
@ -140,43 +225,65 @@ fun FeedScreen(
)
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Show result count badge when searching
if (isSearchActive && searchQuery.isNotBlank() && !isSearching && searchResults.isNotEmpty()) {
item {
SearchResultsHeader(count = searchResultCount, query = searchQuery)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
if (isSearchActive && searchQuery.isNotBlank()) {
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
highlightQuery = searchQuery
)
} else {
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
if (!isSearchActive && state.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
if (!isSearchActive) {
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
if (!isSearchActive && state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
},
@ -191,11 +298,128 @@ fun FeedScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchTopBar(
query: String,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onClear: () -> Unit,
focusRequester: FocusRequester
) {
TopAppBar(
title = {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search posts...") },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = onClear) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
}
}
}
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
}
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
fun SearchResultsHeader(count: Int, query: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (count == 1) "result for \"$query\"" else "results for \"$query\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun RecentSearchesList(
recentSearches: List<String>,
onSearchTap: (String) -> Unit,
onRemove: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(
text = "Recent searches",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
recentSearches.forEach { query ->
ListItem(
headlineContent = { Text(query) },
leadingContent = {
Icon(
Icons.Default.History,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = {
IconButton(onClick = { onRemove(query) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(18.dp)
)
}
},
modifier = Modifier.clickable { onSearchTap(query) }
)
}
}
}
@Composable
fun PostCard(
post: FeedPost,
onClick: () -> Unit,
onCancelQueue: () -> Unit
onCancelQueue: () -> Unit,
highlightQuery: String? = null
) {
var expanded by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) {
@ -231,13 +455,21 @@ fun PostCard(
Spacer(modifier = Modifier.height(8.dp))
// Content
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
// Content with optional highlighting
if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText(
text = displayText,
query = highlightQuery,
maxLines = if (expanded) Int.MAX_VALUE else 8
)
} else {
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
}
if (!expanded && post.textContent.length > 280) {
TextButton(
@ -324,6 +556,63 @@ fun PostCard(
}
}
/**
* Displays text with matching substrings highlighted in bold.
* Case-insensitive matching.
*/
@Composable
fun HighlightedText(
text: String,
query: String,
maxLines: Int = Int.MAX_VALUE
) {
val annotatedString = buildHighlightedString(text, query)
Text(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
}
/**
* Builds an AnnotatedString with all case-insensitive occurrences of [query] in [text]
* highlighted with bold + primary color styling.
*/
fun buildHighlightedString(
text: String,
query: String
) = buildAnnotatedString {
if (query.isBlank()) {
append(text)
return@buildAnnotatedString
}
val textLower = text.lowercase()
val queryLower = query.lowercase()
var currentIndex = 0
while (currentIndex < text.length) {
val matchIndex = textLower.indexOf(queryLower, currentIndex)
if (matchIndex == -1) {
append(text.substring(currentIndex))
break
}
// Add text before match
if (matchIndex > currentIndex) {
append(text.substring(currentIndex, matchIndex))
}
// Add highlighted match
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(text.substring(matchIndex, matchIndex + query.length))
}
currentIndex = matchIndex + query.length
}
}
@Composable
fun StatusBadge(post: FeedPost) {
val (label, color) = when {

View file

@ -1,10 +1,14 @@
package com.swoosh.microblog.ui.feed
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.net.ConnectException
@ -19,17 +23,57 @@ import javax.net.ssl.SSLException
class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val searchHistoryManager = SearchHistoryManager(application)
private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _searchResults = MutableStateFlow<List<FeedPost>>(emptyList())
val searchResults: StateFlow<List<FeedPost>> = _searchResults.asStateFlow()
private val _isSearchActive = MutableStateFlow(false)
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
private val _searchResultCount = MutableStateFlow(0)
val searchResultCount: StateFlow<Int> = _searchResultCount.asStateFlow()
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
private var currentPage = 1
private var hasMorePages = true
private var remotePosts = listOf<FeedPost>()
private var searchJob: Job? = null
init {
observeLocalPosts()
observeSearchQuery()
refresh()
_recentSearches.value = searchHistoryManager.getRecentSearches()
}
@OptIn(FlowPreview::class)
private fun observeSearchQuery() {
viewModelScope.launch {
_searchQuery
.debounce(300)
.distinctUntilChanged()
.collect { query ->
if (query.isBlank()) {
_searchResults.value = emptyList()
_searchResultCount.value = 0
_isSearching.value = false
} else {
performSearch(query)
}
}
}
}
private fun observeLocalPosts() {
@ -43,6 +87,76 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
}
fun activateSearch() {
_isSearchActive.value = true
_recentSearches.value = searchHistoryManager.getRecentSearches()
}
fun deactivateSearch() {
_isSearchActive.value = false
_searchQuery.value = ""
_searchResults.value = emptyList()
_searchResultCount.value = 0
_isSearching.value = false
}
fun clearSearchQuery() {
_searchQuery.value = ""
_searchResults.value = emptyList()
_searchResultCount.value = 0
_isSearching.value = false
}
fun onRecentSearchTap(query: String) {
_searchQuery.value = query
}
fun removeRecentSearch(query: String) {
searchHistoryManager.removeSearch(query)
_recentSearches.value = searchHistoryManager.getRecentSearches()
}
private fun performSearch(query: String) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
_isSearching.value = true
try {
// Search local posts
val localResults = repository.searchLocalPosts(query)
.map { it.toFeedPost() }
// Search remote posts
val remoteResults = try {
repository.searchRemotePosts(query)
.getOrDefault(emptyList())
.map { it.toFeedPost() }
} catch (e: Exception) {
emptyList()
}
// Deduplicate: prefer remote version when ghostId matches
val deduplicatedResults = deduplicateResults(localResults, remoteResults)
_searchResults.value = deduplicatedResults
_searchResultCount.value = deduplicatedResults.size
_isSearching.value = false
// Save to recent searches
searchHistoryManager.addSearch(query)
_recentSearches.value = searchHistoryManager.getRecentSearches()
} catch (e: Exception) {
_isSearching.value = false
_searchResults.value = emptyList()
_searchResultCount.value = 0
}
}
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
@ -173,6 +287,29 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = true,
queueStatus = queueStatus
)
companion object {
/**
* Deduplicates local and remote search results.
* When a local post has a ghostId matching a remote post, the remote version is preferred.
* Local-only posts (no ghostId) are always included.
*/
fun deduplicateResults(
localResults: List<FeedPost>,
remoteResults: List<FeedPost>
): List<FeedPost> {
val remoteGhostIds = remoteResults.mapNotNull { it.ghostId }.toSet()
// Include local posts that don't have a matching remote post
val uniqueLocalPosts = localResults.filter { localPost ->
localPost.ghostId == null || localPost.ghostId !in remoteGhostIds
}
return (uniqueLocalPosts + remoteResults).sortedByDescending { post ->
post.publishedAt ?: post.createdAt ?: ""
}
}
}
}
data class FeedUiState(
@ -183,6 +320,52 @@ data class FeedUiState(
val isConnectionError: Boolean = false
)
/**
* Manages recent search history stored in SharedPreferences.
* Stores up to 5 most recent search queries.
*/
class SearchHistoryManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
PREFS_NAME, Context.MODE_PRIVATE
)
fun getRecentSearches(): List<String> {
val raw = prefs.getString(KEY_RECENT_SEARCHES, null) ?: return emptyList()
return raw.split(SEPARATOR).filter { it.isNotBlank() }
}
fun addSearch(query: String) {
val trimmed = query.trim()
if (trimmed.isBlank()) return
val current = getRecentSearches().toMutableList()
current.remove(trimmed) // Remove if already exists
current.add(0, trimmed) // Add to front
// Keep only last MAX_HISTORY items
val limited = current.take(MAX_HISTORY)
prefs.edit().putString(KEY_RECENT_SEARCHES, limited.joinToString(SEPARATOR)).apply()
}
fun removeSearch(query: String) {
val current = getRecentSearches().toMutableList()
current.remove(query.trim())
prefs.edit().putString(KEY_RECENT_SEARCHES, current.joinToString(SEPARATOR)).apply()
}
fun clearAll() {
prefs.edit().remove(KEY_RECENT_SEARCHES).apply()
}
companion object {
const val PREFS_NAME = "swoosh_search_history"
const val KEY_RECENT_SEARCHES = "recent_searches"
const val SEPARATOR = "\u001F" // Unit separator character
const val MAX_HISTORY = 5
}
}
fun formatRelativeTime(isoString: String?): String {
if (isoString == null) return ""
return try {

View file

@ -0,0 +1,415 @@
package com.swoosh.microblog.ui.feed
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.QueueStatus
import org.junit.Assert.*
import org.junit.Test
/**
* Pure unit tests for search deduplication logic and text highlighting.
* No Android framework dependencies needed.
*/
class SearchDeduplicationTest {
@Test
fun `deduplicateResults returns all posts when no overlap`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = null, title = "Local Draft")
)
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Remote Post")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(2, result.size)
}
@Test
fun `deduplicateResults prefers remote when ghostId matches`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = "ghost-1", title = "Local Version", textContent = "old content")
)
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Remote Version", textContent = "new content")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(1, result.size)
assertEquals("Remote Version", result[0].title)
assertEquals("new content", result[0].textContent)
}
@Test
fun `deduplicateResults keeps local posts without ghostId`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = null, title = "Draft 1"),
createFeedPost(localId = 2, ghostId = null, title = "Draft 2")
)
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Published Post")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(3, result.size)
}
@Test
fun `deduplicateResults with empty local returns only remote`() {
val local = emptyList<FeedPost>()
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Remote 1"),
createFeedPost(ghostId = "ghost-2", title = "Remote 2")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(2, result.size)
}
@Test
fun `deduplicateResults with empty remote returns only local`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = null, title = "Draft 1")
)
val remote = emptyList<FeedPost>()
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(1, result.size)
assertEquals("Draft 1", result[0].title)
}
@Test
fun `deduplicateResults with both empty returns empty list`() {
val result = FeedViewModel.deduplicateResults(emptyList(), emptyList())
assertTrue(result.isEmpty())
}
@Test
fun `deduplicateResults removes multiple local duplicates of same ghostId`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = "ghost-1", title = "Local V1"),
createFeedPost(localId = 2, ghostId = "ghost-1", title = "Local V2")
)
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Remote Version")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(1, result.size)
assertEquals("Remote Version", result[0].title)
}
@Test
fun `deduplicateResults handles mixed local with and without ghostIds`() {
val local = listOf(
createFeedPost(localId = 1, ghostId = null, title = "Draft"),
createFeedPost(localId = 2, ghostId = "ghost-1", title = "Local Synced")
)
val remote = listOf(
createFeedPost(ghostId = "ghost-1", title = "Remote Synced"),
createFeedPost(ghostId = "ghost-2", title = "Remote Only")
)
val result = FeedViewModel.deduplicateResults(local, remote)
assertEquals(3, result.size)
val titles = result.map { it.title }
assertTrue(titles.contains("Draft"))
assertTrue(titles.contains("Remote Synced"))
assertTrue(titles.contains("Remote Only"))
assertFalse(titles.contains("Local Synced"))
}
private fun createFeedPost(
localId: Long? = null,
ghostId: String? = null,
title: String = "",
textContent: String = "",
status: String = "draft",
publishedAt: String? = null,
createdAt: String? = null
) = FeedPost(
localId = localId,
ghostId = ghostId,
title = title,
textContent = textContent,
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status,
publishedAt = publishedAt,
createdAt = createdAt,
updatedAt = null,
isLocal = localId != null,
queueStatus = QueueStatus.NONE
)
}
/**
* Tests for text highlighting logic used in search results.
* Pure unit tests, no Android dependencies.
*/
class SearchHighlightTest {
@Test
fun `buildHighlightedString with no match returns plain text`() {
val result = buildHighlightedString("Hello World", "xyz")
assertEquals("Hello World", result.text)
assertEquals(0, result.spanStyles.size)
}
@Test
fun `buildHighlightedString highlights single match`() {
val result = buildHighlightedString("Hello World", "World")
assertEquals("Hello World", result.text)
assertEquals(1, result.spanStyles.size)
assertEquals(6, result.spanStyles[0].start)
assertEquals(11, result.spanStyles[0].end)
}
@Test
fun `buildHighlightedString highlights multiple matches`() {
val result = buildHighlightedString("cat and cat and cat", "cat")
assertEquals("cat and cat and cat", result.text)
assertEquals(3, result.spanStyles.size)
}
@Test
fun `buildHighlightedString is case insensitive`() {
val result = buildHighlightedString("Hello HELLO hello", "hello")
assertEquals("Hello HELLO hello", result.text)
assertEquals(3, result.spanStyles.size)
}
@Test
fun `buildHighlightedString with empty query returns plain text`() {
val result = buildHighlightedString("Hello World", "")
assertEquals("Hello World", result.text)
assertEquals(0, result.spanStyles.size)
}
@Test
fun `buildHighlightedString with blank query returns plain text`() {
val result = buildHighlightedString("Hello World", " ")
assertEquals("Hello World", result.text)
assertEquals(0, result.spanStyles.size)
}
@Test
fun `buildHighlightedString with query at start of text`() {
val result = buildHighlightedString("Hello World", "Hello")
assertEquals("Hello World", result.text)
assertEquals(1, result.spanStyles.size)
assertEquals(0, result.spanStyles[0].start)
assertEquals(5, result.spanStyles[0].end)
}
@Test
fun `buildHighlightedString with query at end of text`() {
val result = buildHighlightedString("Hello World", "World")
assertEquals("Hello World", result.text)
assertEquals(1, result.spanStyles.size)
assertEquals(6, result.spanStyles[0].start)
assertEquals(11, result.spanStyles[0].end)
}
@Test
fun `buildHighlightedString with entire text matching`() {
val result = buildHighlightedString("hello", "hello")
assertEquals("hello", result.text)
assertEquals(1, result.spanStyles.size)
assertEquals(0, result.spanStyles[0].start)
assertEquals(5, result.spanStyles[0].end)
}
@Test
fun `buildHighlightedString preserves original case`() {
val result = buildHighlightedString("Hello WORLD", "hello")
assertEquals("Hello WORLD", result.text)
}
@Test
fun `buildHighlightedString with overlapping potential matches`() {
val result = buildHighlightedString("aaa", "aa")
assertEquals("aaa", result.text)
// Should find first match at 0-2, then next search starts at 2
assertEquals(1, result.spanStyles.size)
assertEquals(0, result.spanStyles[0].start)
assertEquals(2, result.spanStyles[0].end)
}
@Test
fun `buildHighlightedString with single character query`() {
val result = buildHighlightedString("banana", "a")
assertEquals("banana", result.text)
assertEquals(3, result.spanStyles.size)
}
}
/**
* Tests for SearchHistoryManager.
* Uses Robolectric for SharedPreferences access.
*/
@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
@org.robolectric.annotation.Config(
sdk = [28],
manifest = org.robolectric.annotation.Config.NONE,
application = android.app.Application::class
)
class SearchHistoryManagerTest {
private lateinit var searchHistoryManager: SearchHistoryManager
@org.junit.Before
fun setup() {
val context = org.robolectric.RuntimeEnvironment.getApplication()
context.getSharedPreferences(SearchHistoryManager.PREFS_NAME, android.content.Context.MODE_PRIVATE)
.edit().clear().apply()
searchHistoryManager = SearchHistoryManager(context)
}
@Test
fun `getRecentSearches returns empty list initially`() {
val result = searchHistoryManager.getRecentSearches()
assertTrue(result.isEmpty())
}
@Test
fun `addSearch adds query to history`() {
searchHistoryManager.addSearch("test query")
val result = searchHistoryManager.getRecentSearches()
assertEquals(1, result.size)
assertEquals("test query", result[0])
}
@Test
fun `addSearch adds newest first`() {
searchHistoryManager.addSearch("first")
searchHistoryManager.addSearch("second")
val result = searchHistoryManager.getRecentSearches()
assertEquals("second", result[0])
assertEquals("first", result[1])
}
@Test
fun `addSearch limits to MAX_HISTORY items`() {
repeat(7) { i ->
searchHistoryManager.addSearch("query $i")
}
val result = searchHistoryManager.getRecentSearches()
assertEquals(SearchHistoryManager.MAX_HISTORY, result.size)
}
@Test
fun `addSearch deduplicates same query`() {
searchHistoryManager.addSearch("test")
searchHistoryManager.addSearch("other")
searchHistoryManager.addSearch("test") // Duplicate
val result = searchHistoryManager.getRecentSearches()
assertEquals(2, result.size)
assertEquals("test", result[0]) // Most recent
assertEquals("other", result[1])
}
@Test
fun `addSearch trims whitespace`() {
searchHistoryManager.addSearch(" test ")
val result = searchHistoryManager.getRecentSearches()
assertEquals(1, result.size)
assertEquals("test", result[0])
}
@Test
fun `addSearch ignores blank queries`() {
searchHistoryManager.addSearch("")
searchHistoryManager.addSearch(" ")
val result = searchHistoryManager.getRecentSearches()
assertTrue(result.isEmpty())
}
@Test
fun `removeSearch removes specific query`() {
searchHistoryManager.addSearch("first")
searchHistoryManager.addSearch("second")
searchHistoryManager.addSearch("third")
searchHistoryManager.removeSearch("second")
val result = searchHistoryManager.getRecentSearches()
assertEquals(2, result.size)
assertFalse(result.contains("second"))
}
@Test
fun `removeSearch does nothing for nonexistent query`() {
searchHistoryManager.addSearch("first")
searchHistoryManager.removeSearch("nonexistent")
val result = searchHistoryManager.getRecentSearches()
assertEquals(1, result.size)
}
@Test
fun `clearAll removes all searches`() {
searchHistoryManager.addSearch("first")
searchHistoryManager.addSearch("second")
searchHistoryManager.clearAll()
val result = searchHistoryManager.getRecentSearches()
assertTrue(result.isEmpty())
}
@Test
fun `search history persists across instances`() {
searchHistoryManager.addSearch("persistent query")
val context = org.robolectric.RuntimeEnvironment.getApplication()
val newManager = SearchHistoryManager(context)
val result = newManager.getRecentSearches()
assertEquals(1, result.size)
assertEquals("persistent query", result[0])
}
@Test
fun `addSearch moves existing query to front`() {
searchHistoryManager.addSearch("first")
searchHistoryManager.addSearch("second")
searchHistoryManager.addSearch("third")
searchHistoryManager.addSearch("first") // Re-add
val result = searchHistoryManager.getRecentSearches()
assertEquals(3, result.size)
assertEquals("first", result[0])
assertEquals("third", result[1])
assertEquals("second", result[2])
}
}