mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add full-text search with debounce, history, and highlighted results
This commit is contained in:
parent
74f42fd2f1
commit
bbc408d5df
6 changed files with 1016 additions and 55 deletions
|
|
@ -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/")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
415
app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt
Normal file
415
app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue