mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
merge: integrate search functionality (resolve conflicts)
This commit is contained in:
commit
e1b59d38a6
8 changed files with 1159 additions and 177 deletions
|
|
@ -1 +0,0 @@
|
|||
{"type":"server-started","port":65178,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65178","screen_dir":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049"}
|
||||
1
.superpowers/brainstorm/17336-1773912049/.server-stopped
Normal file
1
.superpowers/brainstorm/17336-1773912049/.server-stopped
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"reason":"idle timeout","timestamp":1773914269831}
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map.html"}
|
||||
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map-v2.html"}
|
||||
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/waiting.html"}
|
||||
{"type":"server-stopped","reason":"idle timeout"}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,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()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import android.content.ClipData
|
|||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -19,6 +22,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||
|
|
@ -33,6 +37,7 @@ import androidx.compose.material.icons.filled.Refresh
|
|||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
|
|
@ -43,6 +48,8 @@ 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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -50,9 +57,12 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction
|
|||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -83,6 +93,12 @@ fun FeedScreen(
|
|||
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
|
||||
val sortOrder by viewModel.sortOrder.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 context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
@ -91,9 +107,12 @@ fun FeedScreen(
|
|||
// Track which post is pending delete confirmation
|
||||
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
|
||||
|
||||
// Split posts into pinned and regular
|
||||
val pinnedPosts = state.posts.filter { it.featured }
|
||||
val regularPosts = state.posts.filter { !it.featured }
|
||||
// Determine display posts based on search state
|
||||
val displayPosts = if (isSearchActive && searchQuery.isNotBlank()) searchResults else state.posts
|
||||
|
||||
// Split posts into pinned and regular (only when not searching)
|
||||
val pinnedPosts = displayPosts.filter { it.featured }
|
||||
val regularPosts = displayPosts.filter { !it.featured }
|
||||
|
||||
// Pull-to-refresh
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
|
|
@ -101,6 +120,8 @@ fun FeedScreen(
|
|||
onRefresh = { viewModel.refresh() }
|
||||
)
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Infinite scroll trigger
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
|
|
@ -110,7 +131,7 @@ fun FeedScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
if (shouldLoadMore && state.posts.isNotEmpty()) {
|
||||
if (shouldLoadMore && state.posts.isNotEmpty() && !isSearchActive) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
|
|
@ -131,6 +152,15 @@ fun FeedScreen(
|
|||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (isSearchActive) {
|
||||
SearchTopBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::onSearchQueryChange,
|
||||
onClose = viewModel::deactivateSearch,
|
||||
onClear = viewModel::clearSearchQuery,
|
||||
focusRequester = focusRequester
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
|
|
@ -145,6 +175,9 @@ fun FeedScreen(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.activateSearch() }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
if (themeViewModel != null) {
|
||||
val currentMode = themeMode?.value ?: ThemeMode.SYSTEM
|
||||
val (icon, description) = when (currentMode) {
|
||||
|
|
@ -168,11 +201,14 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isSearchActive) {
|
||||
FloatingActionButton(onClick = onCompose) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New post")
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
|
|
@ -181,18 +217,74 @@ fun FeedScreen(
|
|||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Filter chips bar
|
||||
// Filter chips bar (only when not searching)
|
||||
if (!isSearchActive) {
|
||||
FilterChipsBar(
|
||||
activeFilter = activeFilter,
|
||||
onFilterSelected = { viewModel.setFilter(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
if (state.posts.isEmpty() && !state.isRefreshing) {
|
||||
if (state.isConnectionError && state.error != null) {
|
||||
// Connection error empty state
|
||||
Column(
|
||||
|
|
@ -254,13 +346,44 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = state.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Main content: posts list
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (!isSearchActive) Modifier.pullRefresh(pullRefreshState) else Modifier)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSearchActive && searchQuery.isNotBlank()) {
|
||||
// Search results: flat list with highlighting, no swipe actions
|
||||
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
||||
PostCard(
|
||||
post = post,
|
||||
onClick = { onPostClick(post) },
|
||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||
highlightQuery = searchQuery
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal feed: pinned section + swipe actions
|
||||
// Pinned section header
|
||||
if (pinnedPosts.isNotEmpty()) {
|
||||
item(key = "pinned_header") {
|
||||
|
|
@ -335,8 +458,9 @@ fun FeedScreen(
|
|||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoadingMore) {
|
||||
if (!isSearchActive && state.isLoadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
|
|
@ -348,11 +472,13 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
PullRefreshIndicator(
|
||||
refreshing = state.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
|
||||
// Show snackbar for pin/unpin confirmation
|
||||
if (state.snackbarMessage != null) {
|
||||
|
|
@ -387,6 +513,7 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (postPendingDelete != null) {
|
||||
|
|
@ -663,6 +790,122 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
|
|||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PostCardContent(
|
||||
|
|
@ -671,10 +914,11 @@ fun PostCardContent(
|
|||
onCancelQueue: () -> Unit,
|
||||
onShare: () -> Unit = {},
|
||||
onCopyLink: () -> Unit = {},
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onEdit: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onTogglePin: () -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState? = null
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
highlightQuery: String? = null
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
|
|
@ -736,13 +980,21 @@ fun PostCardContent(
|
|||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Content
|
||||
// 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(
|
||||
|
|
@ -1002,17 +1254,76 @@ fun PostCardContent(
|
|||
fun PostCard(
|
||||
post: FeedPost,
|
||||
onClick: () -> Unit,
|
||||
onCancelQueue: () -> Unit
|
||||
onCancelQueue: () -> Unit,
|
||||
highlightQuery: String? = null
|
||||
) {
|
||||
PostCardContent(
|
||||
post = post,
|
||||
onClick = onClick,
|
||||
onCancelQueue = onCancelQueue,
|
||||
onEdit = {},
|
||||
onDelete = {}
|
||||
onDelete = {},
|
||||
highlightQuery = highlightQuery
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
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.CredentialsManager
|
||||
import com.swoosh.microblog.data.FeedPreferences
|
||||
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
|
||||
|
|
@ -29,6 +32,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
private val repository = PostRepository(application)
|
||||
private val feedPreferences = FeedPreferences(application)
|
||||
private val searchHistoryManager = SearchHistoryManager(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(FeedUiState())
|
||||
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -42,16 +46,55 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
private val _sortOrder = MutableStateFlow(feedPreferences.sortOrder)
|
||||
val sortOrder: StateFlow<SortOrder> = _sortOrder.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 localPostsJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
observeLocalPosts()
|
||||
observeSearchQuery()
|
||||
if (CredentialsManager(getApplication()).isConfigured) {
|
||||
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() {
|
||||
|
|
@ -84,6 +127,76 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
refresh()
|
||||
}
|
||||
|
||||
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) }
|
||||
|
|
@ -324,6 +437,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(
|
||||
|
|
@ -335,6 +471,52 @@ data class FeedUiState(
|
|||
val snackbarMessage: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
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