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("page") page: Int = 1,
@Query("include") include: String = "authors", @Query("include") include: String = "authors",
@Query("formats") formats: String = "html,plaintext,mobiledoc", @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> ): Response<PostsResponse>
@POST("ghost/api/admin/posts/") @POST("ghost/api/admin/posts/")

View file

@ -31,6 +31,9 @@ interface LocalPostDao {
@Delete @Delete
suspend fun deletePost(post: LocalPost) 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") @Query("DELETE FROM local_posts WHERE localId = :localId")
suspend fun deleteById(localId: Long) suspend fun deleteById(localId: Long)

View file

@ -120,6 +120,76 @@ class PostRepository(private val context: Context) {
return tempFile 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 --- // --- Local operations ---
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts() fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()

View file

@ -1,5 +1,8 @@
package com.swoosh.microblog.ui.feed 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -19,9 +20,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -38,6 +45,12 @@ fun FeedScreen(
viewModel: FeedViewModel = viewModel() viewModel: FeedViewModel = viewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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() val listState = rememberLazyListState()
// Pull-to-refresh // Pull-to-refresh
@ -55,28 +68,46 @@ fun FeedScreen(
} }
LaunchedEffect(shouldLoadMore) { LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && state.posts.isNotEmpty()) { if (shouldLoadMore && state.posts.isNotEmpty() && !isSearchActive) {
viewModel.loadMore() viewModel.loadMore()
} }
} }
val displayPosts = if (isSearchActive && searchQuery.isNotBlank()) searchResults else state.posts
val focusRequester = remember { FocusRequester() }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( if (isSearchActive) {
title = { Text("Swoosh") }, SearchTopBar(
actions = { query = searchQuery,
IconButton(onClick = { viewModel.refresh() }) { onQueryChange = viewModel::onSearchQueryChange,
Icon(Icons.Default.Refresh, contentDescription = "Refresh") 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 = {
FloatingActionButton(onClick = onCompose) { if (!isSearchActive) {
Icon(Icons.Default.Add, contentDescription = "New post") FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
} }
} }
) { padding -> ) { padding ->
@ -84,9 +115,63 @@ fun FeedScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .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) { if (state.isConnectionError && state.error != null) {
// Connection error empty state // Connection error empty state
Column( 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( items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
state = listState, if (isSearchActive && searchQuery.isNotBlank()) {
modifier = Modifier.fillMaxSize(), PostCard(
contentPadding = PaddingValues(vertical = 8.dp) post = post,
) { onClick = { onPostClick(post) },
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> onCancelQueue = { viewModel.cancelQueuedPost(post) },
PostCard( highlightQuery = searchQuery
post = post, )
onClick = { onPostClick(post) }, } else {
onCancelQueue = { viewModel.cancelQueuedPost(post) } PostCard(
) post = post,
} onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
}
if (state.isLoadingMore) { if (!isSearchActive && state.isLoadingMore) {
item { item {
Box( Box(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier
contentAlignment = Alignment.Center .fillMaxWidth()
) { .padding(16.dp),
CircularProgressIndicator(modifier = Modifier.size(24.dp)) contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} }
} }
} }
} }
PullRefreshIndicator( if (!isSearchActive) {
refreshing = state.isRefreshing, PullRefreshIndicator(
state = pullRefreshState, refreshing = state.isRefreshing,
modifier = Modifier.align(Alignment.TopCenter) state = pullRefreshState,
) modifier = Modifier.align(Alignment.TopCenter)
)
}
// Show non-connection errors as snackbar (when posts are visible) // 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( Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
action = { action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") } 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 @Composable
fun PostCard( fun PostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit onCancelQueue: () -> Unit,
highlightQuery: String? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) { val displayText = if (expanded || post.textContent.length <= 280) {
@ -231,13 +455,21 @@ fun PostCard(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Content // Content with optional highlighting
Text( if (highlightQuery != null && highlightQuery.isNotBlank()) {
text = displayText, HighlightedText(
style = MaterialTheme.typography.bodyMedium, text = displayText,
maxLines = if (expanded) Int.MAX_VALUE else 8, query = highlightQuery,
overflow = TextOverflow.Ellipsis 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) { if (!expanded && post.textContent.length > 280) {
TextButton( 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 @Composable
fun StatusBadge(post: FeedPost) { fun StatusBadge(post: FeedPost) {
val (label, color) = when { val (label, color) = when {

View file

@ -1,10 +1,14 @@
package com.swoosh.microblog.ui.feed package com.swoosh.microblog.ui.feed
import android.app.Application import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.ConnectException import java.net.ConnectException
@ -19,17 +23,57 @@ import javax.net.ssl.SSLException
class FeedViewModel(application: Application) : AndroidViewModel(application) { class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application) private val repository = PostRepository(application)
private val searchHistoryManager = SearchHistoryManager(application)
private val _uiState = MutableStateFlow(FeedUiState()) private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow() 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 currentPage = 1
private var hasMorePages = true private var hasMorePages = true
private var remotePosts = listOf<FeedPost>() private var remotePosts = listOf<FeedPost>()
private var searchJob: Job? = null
init { init {
observeLocalPosts() observeLocalPosts()
observeSearchQuery()
refresh() 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() { 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() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) } _uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
@ -173,6 +287,29 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = true, isLocal = true,
queueStatus = queueStatus 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( data class FeedUiState(
@ -183,6 +320,52 @@ data class FeedUiState(
val isConnectionError: Boolean = false 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 { fun formatRelativeTime(isoString: String?): String {
if (isoString == null) return "" if (isoString == null) return ""
return try { 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])
}
}