From bbc408d5dfe258906ad4798542ffbcf929ccac12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:10 +0100 Subject: [PATCH] feat: add full-text search with debounce, history, and highlighted results --- .../microblog/data/api/GhostApiService.kt | 3 +- .../swoosh/microblog/data/db/LocalPostDao.kt | 3 + .../data/repository/PostRepository.kt | 70 +++ .../swoosh/microblog/ui/feed/FeedScreen.kt | 397 ++++++++++++++--- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 183 ++++++++ .../swoosh/microblog/ui/feed/SearchTest.kt | 415 ++++++++++++++++++ 6 files changed, 1016 insertions(+), 55 deletions(-) create mode 100644 app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index b51ebe8..3affb54 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -15,7 +15,8 @@ interface GhostApiService { @Query("page") page: Int = 1, @Query("include") include: String = "authors", @Query("formats") formats: String = "html,plaintext,mobiledoc", - @Query("order") order: String = "created_at desc" + @Query("order") order: String = "created_at desc", + @Query("filter") filter: String? = null ): Response @POST("ghost/api/admin/posts/") diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 02a5651..6f3fd9d 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -31,6 +31,9 @@ interface LocalPostDao { @Delete suspend fun deletePost(post: LocalPost) + @Query("SELECT * FROM local_posts WHERE content LIKE '%' || :query || '%' OR title LIKE '%' || :query || '%' ORDER BY createdAt DESC") + suspend fun searchPosts(query: String): List + @Query("DELETE FROM local_posts WHERE localId = :localId") suspend fun deleteById(localId: Long) diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..96d1c9b 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -120,6 +120,76 @@ class PostRepository(private val context: Context) { return tempFile } + // --- Search operations --- + + suspend fun searchLocalPosts(query: String): List = + withContext(Dispatchers.IO) { + dao.searchPosts(query) + } + + suspend fun searchRemotePosts(query: String): Result> = + 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> { + val allPosts = mutableListOf() + 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>>> = + 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> = dao.getAllPosts() diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..bb34773 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -1,5 +1,8 @@ package com.swoosh.microblog.ui.feed +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -7,10 +10,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -19,9 +20,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -38,6 +45,12 @@ fun FeedScreen( viewModel: FeedViewModel = viewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() + val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle() + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + val searchResultCount by viewModel.searchResultCount.collectAsStateWithLifecycle() + val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle() val listState = rememberLazyListState() // Pull-to-refresh @@ -55,28 +68,46 @@ fun FeedScreen( } LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore && state.posts.isNotEmpty()) { + if (shouldLoadMore && state.posts.isNotEmpty() && !isSearchActive) { viewModel.loadMore() } } + val displayPosts = if (isSearchActive && searchQuery.isNotBlank()) searchResults else state.posts + val focusRequester = remember { FocusRequester() } + Scaffold( topBar = { - TopAppBar( - title = { Text("Swoosh") }, - actions = { - IconButton(onClick = { viewModel.refresh() }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") + if (isSearchActive) { + SearchTopBar( + query = searchQuery, + onQueryChange = viewModel::onSearchQueryChange, + onClose = viewModel::deactivateSearch, + onClear = viewModel::clearSearchQuery, + focusRequester = focusRequester + ) + } else { + TopAppBar( + title = { Text("Swoosh") }, + actions = { + IconButton(onClick = { viewModel.activateSearch() }) { + Icon(Icons.Default.Search, contentDescription = "Search") + } + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } } - IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "Settings") - } - } - ) + ) + } }, floatingActionButton = { - FloatingActionButton(onClick = onCompose) { - Icon(Icons.Default.Add, contentDescription = "New post") + if (!isSearchActive) { + FloatingActionButton(onClick = onCompose) { + Icon(Icons.Default.Add, contentDescription = "New post") + } } } ) { padding -> @@ -84,9 +115,63 @@ fun FeedScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .pullRefresh(pullRefreshState) + .then(if (!isSearchActive) Modifier.pullRefresh(pullRefreshState) else Modifier) ) { - if (state.posts.isEmpty() && !state.isRefreshing) { + // Show recent searches when search is active but query is empty + if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) { + RecentSearchesList( + recentSearches = recentSearches, + onSearchTap = viewModel::onRecentSearchTap, + onRemove = viewModel::removeRecentSearch + ) + } else if (isSearchActive && searchQuery.isNotBlank() && isSearching) { + // Searching indicator + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Searching...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else if (isSearchActive && searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching) { + // No results empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp) + ) { + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No results found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No posts matching \"$searchQuery\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } else if (!isSearchActive && displayPosts.isEmpty() && !state.isRefreshing) { if (state.isConnectionError && state.error != null) { // Connection error empty state Column( @@ -140,43 +225,65 @@ fun FeedScreen( ) } } - } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + // Show result count badge when searching + if (isSearchActive && searchQuery.isNotBlank() && !isSearching && searchResults.isNotEmpty()) { + item { + SearchResultsHeader(count = searchResultCount, query = searchQuery) + } + } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> - PostCard( - post = post, - onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } - ) - } + items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> + if (isSearchActive && searchQuery.isNotBlank()) { + PostCard( + post = post, + onClick = { onPostClick(post) }, + onCancelQueue = { viewModel.cancelQueuedPost(post) }, + highlightQuery = searchQuery + ) + } else { + PostCard( + post = post, + onClick = { onPostClick(post) }, + onCancelQueue = { viewModel.cancelQueuedPost(post) } + ) + } + } - if (state.isLoadingMore) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + if (!isSearchActive && state.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } } } } } - PullRefreshIndicator( - refreshing = state.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) + if (!isSearchActive) { + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } // Show non-connection errors as snackbar (when posts are visible) - if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { + if (!isSearchActive && state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { Snackbar( - modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), action = { TextButton(onClick = { viewModel.refresh() }) { Text("Retry") } }, @@ -191,11 +298,128 @@ fun FeedScreen( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchTopBar( + query: String, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, + onClear: () -> Unit, + focusRequester: FocusRequester +) { + TopAppBar( + title = { + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search posts...") }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + } + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") + } + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +fun SearchResultsHeader(count: Int, query: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "$count", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (count == 1) "result for \"$query\"" else "results for \"$query\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun RecentSearchesList( + recentSearches: List, + onSearchTap: (String) -> Unit, + onRemove: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text( + text = "Recent searches", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + recentSearches.forEach { query -> + ListItem( + headlineContent = { Text(query) }, + leadingContent = { + Icon( + Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = { + IconButton(onClick = { onRemove(query) }) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp) + ) + } + }, + modifier = Modifier.clickable { onSearchTap(query) } + ) + } + } +} + @Composable fun PostCard( post: FeedPost, onClick: () -> Unit, - onCancelQueue: () -> Unit + onCancelQueue: () -> Unit, + highlightQuery: String? = null ) { var expanded by remember { mutableStateOf(false) } val displayText = if (expanded || post.textContent.length <= 280) { @@ -231,13 +455,21 @@ fun PostCard( Spacer(modifier = Modifier.height(8.dp)) - // Content - Text( - text = displayText, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (expanded) Int.MAX_VALUE else 8, - overflow = TextOverflow.Ellipsis - ) + // Content with optional highlighting + if (highlightQuery != null && highlightQuery.isNotBlank()) { + HighlightedText( + text = displayText, + query = highlightQuery, + maxLines = if (expanded) Int.MAX_VALUE else 8 + ) + } else { + Text( + text = displayText, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (expanded) Int.MAX_VALUE else 8, + overflow = TextOverflow.Ellipsis + ) + } if (!expanded && post.textContent.length > 280) { TextButton( @@ -324,6 +556,63 @@ fun PostCard( } } +/** + * Displays text with matching substrings highlighted in bold. + * Case-insensitive matching. + */ +@Composable +fun HighlightedText( + text: String, + query: String, + maxLines: Int = Int.MAX_VALUE +) { + val annotatedString = buildHighlightedString(text, query) + Text( + text = annotatedString, + style = MaterialTheme.typography.bodyMedium, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) +} + +/** + * Builds an AnnotatedString with all case-insensitive occurrences of [query] in [text] + * highlighted with bold + primary color styling. + */ +fun buildHighlightedString( + text: String, + query: String +) = buildAnnotatedString { + if (query.isBlank()) { + append(text) + return@buildAnnotatedString + } + + val textLower = text.lowercase() + val queryLower = query.lowercase() + var currentIndex = 0 + + while (currentIndex < text.length) { + val matchIndex = textLower.indexOf(queryLower, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + + // Add text before match + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + + // Add highlighted match + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(text.substring(matchIndex, matchIndex + query.length)) + } + + currentIndex = matchIndex + query.length + } +} + @Composable fun StatusBadge(post: FeedPost) { val (label, color) = when { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..392c7b6 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -1,10 +1,14 @@ package com.swoosh.microblog.ui.feed import android.app.Application +import android.content.Context +import android.content.SharedPreferences import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.PostRepository +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.net.ConnectException @@ -19,17 +23,57 @@ import javax.net.ssl.SSLException class FeedViewModel(application: Application) : AndroidViewModel(application) { private val repository = PostRepository(application) + private val searchHistoryManager = SearchHistoryManager(application) private val _uiState = MutableStateFlow(FeedUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults.asStateFlow() + + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + private val _searchResultCount = MutableStateFlow(0) + val searchResultCount: StateFlow = _searchResultCount.asStateFlow() + + private val _recentSearches = MutableStateFlow>(emptyList()) + val recentSearches: StateFlow> = _recentSearches.asStateFlow() + private var currentPage = 1 private var hasMorePages = true private var remotePosts = listOf() + private var searchJob: Job? = null init { observeLocalPosts() + observeSearchQuery() refresh() + _recentSearches.value = searchHistoryManager.getRecentSearches() + } + + @OptIn(FlowPreview::class) + private fun observeSearchQuery() { + viewModelScope.launch { + _searchQuery + .debounce(300) + .distinctUntilChanged() + .collect { query -> + if (query.isBlank()) { + _searchResults.value = emptyList() + _searchResultCount.value = 0 + _isSearching.value = false + } else { + performSearch(query) + } + } + } } private fun observeLocalPosts() { @@ -43,6 +87,76 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } } + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun activateSearch() { + _isSearchActive.value = true + _recentSearches.value = searchHistoryManager.getRecentSearches() + } + + fun deactivateSearch() { + _isSearchActive.value = false + _searchQuery.value = "" + _searchResults.value = emptyList() + _searchResultCount.value = 0 + _isSearching.value = false + } + + fun clearSearchQuery() { + _searchQuery.value = "" + _searchResults.value = emptyList() + _searchResultCount.value = 0 + _isSearching.value = false + } + + fun onRecentSearchTap(query: String) { + _searchQuery.value = query + } + + fun removeRecentSearch(query: String) { + searchHistoryManager.removeSearch(query) + _recentSearches.value = searchHistoryManager.getRecentSearches() + } + + private fun performSearch(query: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch { + _isSearching.value = true + + try { + // Search local posts + val localResults = repository.searchLocalPosts(query) + .map { it.toFeedPost() } + + // Search remote posts + val remoteResults = try { + repository.searchRemotePosts(query) + .getOrDefault(emptyList()) + .map { it.toFeedPost() } + } catch (e: Exception) { + emptyList() + } + + // Deduplicate: prefer remote version when ghostId matches + val deduplicatedResults = deduplicateResults(localResults, remoteResults) + + _searchResults.value = deduplicatedResults + _searchResultCount.value = deduplicatedResults.size + _isSearching.value = false + + // Save to recent searches + searchHistoryManager.addSearch(query) + _recentSearches.value = searchHistoryManager.getRecentSearches() + } catch (e: Exception) { + _isSearching.value = false + _searchResults.value = emptyList() + _searchResultCount.value = 0 + } + } + } + fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) } @@ -173,6 +287,29 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { isLocal = true, queueStatus = queueStatus ) + + companion object { + /** + * Deduplicates local and remote search results. + * When a local post has a ghostId matching a remote post, the remote version is preferred. + * Local-only posts (no ghostId) are always included. + */ + fun deduplicateResults( + localResults: List, + remoteResults: List + ): List { + val remoteGhostIds = remoteResults.mapNotNull { it.ghostId }.toSet() + + // Include local posts that don't have a matching remote post + val uniqueLocalPosts = localResults.filter { localPost -> + localPost.ghostId == null || localPost.ghostId !in remoteGhostIds + } + + return (uniqueLocalPosts + remoteResults).sortedByDescending { post -> + post.publishedAt ?: post.createdAt ?: "" + } + } + } } data class FeedUiState( @@ -183,6 +320,52 @@ data class FeedUiState( val isConnectionError: Boolean = false ) +/** + * Manages recent search history stored in SharedPreferences. + * Stores up to 5 most recent search queries. + */ +class SearchHistoryManager(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences( + PREFS_NAME, Context.MODE_PRIVATE + ) + + fun getRecentSearches(): List { + 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 { diff --git a/app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt b/app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt new file mode 100644 index 0000000..e630594 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt @@ -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() + 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() + + 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]) + } +}