merge: integrate search functionality (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:01:24 +01:00
commit e1b59d38a6
No known key found for this signature in database
8 changed files with 1159 additions and 177 deletions

View file

@ -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"}

View file

@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1773914269831}

View file

@ -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.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/animation-map-v2.html"}
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/waiting.html"} {"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/waiting.html"}
{"type":"server-stopped","reason":"idle timeout"}

View file

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

View file

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

View file

@ -4,7 +4,10 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -19,6 +22,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrightnessAuto 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.Settings
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WifiOff 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.FilterList
import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
@ -43,6 +48,8 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.contentDescription
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics 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.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.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -83,6 +93,12 @@ fun FeedScreen(
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle() val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.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 listState = rememberLazyListState()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -91,9 +107,12 @@ fun FeedScreen(
// Track which post is pending delete confirmation // Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) } var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
// Split posts into pinned and regular // Determine display posts based on search state
val pinnedPosts = state.posts.filter { it.featured } val displayPosts = if (isSearchActive && searchQuery.isNotBlank()) searchResults else state.posts
val regularPosts = state.posts.filter { !it.featured }
// 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 // Pull-to-refresh
val pullRefreshState = rememberPullRefreshState( val pullRefreshState = rememberPullRefreshState(
@ -101,6 +120,8 @@ fun FeedScreen(
onRefresh = { viewModel.refresh() } onRefresh = { viewModel.refresh() }
) )
val focusRequester = remember { FocusRequester() }
// Infinite scroll trigger // Infinite scroll trigger
val shouldLoadMore by remember { val shouldLoadMore by remember {
derivedStateOf { derivedStateOf {
@ -110,7 +131,7 @@ fun FeedScreen(
} }
LaunchedEffect(shouldLoadMore) { LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && state.posts.isNotEmpty()) { if (shouldLoadMore && state.posts.isNotEmpty() && !isSearchActive) {
viewModel.loadMore() viewModel.loadMore()
} }
} }
@ -131,47 +152,62 @@ fun FeedScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( if (isSearchActive) {
title = { SearchTopBar(
Column { query = searchQuery,
Text("Swoosh") onQueryChange = viewModel::onSearchQueryChange,
if (activeFilter != PostFilter.ALL) { onClose = viewModel::deactivateSearch,
Text( onClear = viewModel::clearSearchQuery,
text = activeFilter.displayName, focusRequester = focusRequester
style = MaterialTheme.typography.labelSmall, )
color = MaterialTheme.colorScheme.primary } else {
) TopAppBar(
title = {
Column {
Text("Swoosh")
if (activeFilter != PostFilter.ALL) {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
},
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) {
ThemeMode.SYSTEM -> Icons.Default.BrightnessAuto to "Theme: System"
ThemeMode.LIGHT -> Icons.Default.LightMode to "Theme: Light"
ThemeMode.DARK -> Icons.Default.DarkMode to "Theme: Dark"
}
IconButton(onClick = { themeViewModel.cycleThemeMode() }) {
Icon(icon, contentDescription = description)
}
}
SortButton(
currentSort = sortOrder,
onSortSelected = { viewModel.setSortOrder(it) }
)
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
} }
} }
}, )
actions = { }
if (themeViewModel != null) {
val currentMode = themeMode?.value ?: ThemeMode.SYSTEM
val (icon, description) = when (currentMode) {
ThemeMode.SYSTEM -> Icons.Default.BrightnessAuto to "Theme: System"
ThemeMode.LIGHT -> Icons.Default.LightMode to "Theme: Light"
ThemeMode.DARK -> Icons.Default.DarkMode to "Theme: Dark"
}
IconButton(onClick = { themeViewModel.cycleThemeMode() }) {
Icon(icon, contentDescription = description)
}
}
SortButton(
currentSort = sortOrder,
onSortSelected = { viewModel.setSortOrder(it) }
)
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
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")
}
} }
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
@ -181,18 +217,74 @@ fun FeedScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// Filter chips bar // Filter chips bar (only when not searching)
FilterChipsBar( if (!isSearchActive) {
activeFilter = activeFilter, FilterChipsBar(
onFilterSelected = { viewModel.setFilter(it) } activeFilter = activeFilter,
) onFilterSelected = { viewModel.setFilter(it) }
)
}
Box( // Show recent searches when search is active but query is empty
modifier = Modifier if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
.fillMaxSize() RecentSearchesList(
.pullRefresh(pullRefreshState) recentSearches = recentSearches,
) { onSearchTap = viewModel::onRecentSearchTap,
if (state.posts.isEmpty() && !state.isRefreshing) { 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.isConnectionError && state.error != null) { if (state.isConnectionError && state.error != null) {
// Connection error empty state // Connection error empty state
Column( Column(
@ -254,134 +346,169 @@ fun FeedScreen(
} }
} }
} }
}
LazyColumn( PullRefreshIndicator(
state = listState, refreshing = state.isRefreshing,
modifier = Modifier.fillMaxSize(), state = pullRefreshState,
contentPadding = PaddingValues(vertical = 8.dp) modifier = Modifier.align(Alignment.TopCenter)
)
}
} else {
// Main content: posts list
Box(
modifier = Modifier
.fillMaxSize()
.then(if (!isSearchActive) Modifier.pullRefresh(pullRefreshState) else Modifier)
) { ) {
// Pinned section header LazyColumn(
if (pinnedPosts.isNotEmpty()) { state = listState,
item(key = "pinned_header") { modifier = Modifier.fillMaxSize(),
PinnedSectionHeader() contentPadding = PaddingValues(vertical = 8.dp)
) {
// Show result count badge when searching
if (isSearchActive && searchQuery.isNotBlank() && !isSearching && searchResults.isNotEmpty()) {
item {
SearchResultsHeader(count = searchResultCount, query = searchQuery)
}
} }
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
SwipeablePostCard( if (isSearchActive && searchQuery.isNotBlank()) {
post = post, // Search results: flat list with highlighting, no swipe actions
onClick = { onPostClick(post) }, items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
onCancelQueue = { viewModel.cancelQueuedPost(post) }, PostCard(
onShare = { post = post,
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl) onClick = { onPostClick(post) },
if (postUrl != null) { onCancelQueue = { viewModel.cancelQueuedPost(post) },
val shareText = ShareUtils.formatShareContent(post, postUrl) highlightQuery = searchQuery
val sendIntent = Intent(Intent.ACTION_SEND).apply { )
type = "text/plain" }
putExtra(Intent.EXTRA_TEXT, shareText) } else {
// Normal feed: pinned section + swipe actions
// Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
} }
context.startActivity(Intent.createChooser(sendIntent, "Share post")) },
} onCopyLink = {
}, val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
onCopyLink = { if (postUrl != null) {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl) val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
if (postUrl != null) { clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl)) },
} onEdit = { onEditPost(post) },
}, onDelete = { postPendingDelete = post },
onEdit = { onEditPost(post) }, onTogglePin = { viewModel.toggleFeatured(post) },
onDelete = { postPendingDelete = post }, snackbarHostState = snackbarHostState
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
) )
} }
} }
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> if (!isSearchActive && state.isLoadingMore) {
SwipeablePostCard( item {
post = post, Box(
onClick = { onPostClick(post) }, modifier = Modifier.fillMaxWidth().padding(16.dp),
onCancelQueue = { viewModel.cancelQueuedPost(post) }, contentAlignment = Alignment.Center
onShare = { ) {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl) CircularProgressIndicator(modifier = Modifier.size(24.dp))
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
} }
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.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 snackbar for pin/unpin confirmation
if (state.snackbarMessage != null) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
dismissAction = {
TextButton(onClick = viewModel::clearSnackbar) { Text("OK") }
}
) {
Text(state.snackbarMessage!!)
} }
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
}
}
// Show non-connection errors as snackbar (when posts are visible) // Show snackbar for pin/unpin confirmation
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { if (state.snackbarMessage != null) {
Snackbar( Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = { dismissAction = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") } TextButton(onClick = viewModel::clearSnackbar) { Text("OK") }
}, }
dismissAction = { ) {
TextButton(onClick = viewModel::clearError) { Text("Dismiss") } Text(state.snackbarMessage!!)
}
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
}
}
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
},
dismissAction = {
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
}
) {
Text(state.error!!)
} }
) {
Text(state.error!!)
} }
} }
} }
@ -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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PostCardContent( fun PostCardContent(
@ -671,10 +914,11 @@ fun PostCardContent(
onCancelQueue: () -> Unit, onCancelQueue: () -> Unit,
onShare: () -> Unit = {}, onShare: () -> Unit = {},
onCopyLink: () -> Unit = {}, onCopyLink: () -> Unit = {},
onEdit: () -> Unit, onEdit: () -> Unit = {},
onDelete: () -> Unit, onDelete: () -> Unit = {},
onTogglePin: () -> Unit = {}, onTogglePin: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null,
highlightQuery: String? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
@ -736,13 +980,21 @@ fun PostCardContent(
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(
@ -1002,17 +1254,76 @@ fun PostCardContent(
fun PostCard( fun PostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit onCancelQueue: () -> Unit,
highlightQuery: String? = null
) { ) {
PostCardContent( PostCardContent(
post = post, post = post,
onClick = onClick, onClick = onClick,
onCancelQueue = onCancelQueue, onCancelQueue = onCancelQueue,
onEdit = {}, 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 @Composable
fun StatusBadge(post: FeedPost) { fun StatusBadge(post: FeedPost) {
val (label, color) = when { val (label, color) = when {

View file

@ -1,12 +1,15 @@
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.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences import com.swoosh.microblog.data.FeedPreferences
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.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -29,6 +32,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application) private val repository = PostRepository(application)
private val feedPreferences = FeedPreferences(application) private val feedPreferences = FeedPreferences(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()
@ -42,16 +46,55 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _sortOrder = MutableStateFlow(feedPreferences.sortOrder) private val _sortOrder = MutableStateFlow(feedPreferences.sortOrder)
val sortOrder: StateFlow<SortOrder> = _sortOrder.asStateFlow() 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 currentPage = 1
private var hasMorePages = true private var hasMorePages = true
private var remotePosts = listOf<FeedPost>() private var remotePosts = listOf<FeedPost>()
private var localPostsJob: Job? = null private var localPostsJob: Job? = null
private var searchJob: Job? = null
init { init {
observeLocalPosts() observeLocalPosts()
observeSearchQuery()
if (CredentialsManager(getApplication()).isConfigured) { if (CredentialsManager(getApplication()).isConfigured) {
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() {
@ -84,6 +127,76 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
refresh() 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() { 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) }
@ -324,6 +437,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(
@ -335,6 +471,52 @@ data class FeedUiState(
val snackbarMessage: String? = null 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 { fun formatRelativeTime(isoString: String?): String {
if (isoString == null) return "" if (isoString == null) return ""
return try { return try {

View file

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