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-v2.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
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)

View file

@ -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()

View file

@ -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,47 +152,62 @@ fun FeedScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Swoosh")
if (activeFilter != PostFilter.ALL) {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
if (isSearchActive) {
SearchTopBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
onClose = viewModel::deactivateSearch,
onClear = viewModel::clearSearchQuery,
focusRequester = focusRequester
)
} 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(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
if (!isSearchActive) {
FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
@ -181,18 +217,74 @@ fun FeedScreen(
.fillMaxSize()
.padding(padding)
) {
// Filter chips bar
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
)
// Filter chips bar (only when not searching)
if (!isSearchActive) {
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
)
}
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
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) {
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
if (state.isConnectionError && state.error != null) {
// Connection error empty state
Column(
@ -254,134 +346,169 @@ fun FeedScreen(
}
}
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
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)
) {
// Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
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)
}
}
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)
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") {
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)
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
},
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
)
}
}
}
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"))
if (!isSearchActive && state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
},
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(
refreshing = state.isRefreshing,
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!!)
if (!isSearchActive) {
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
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") }
// 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)
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)
@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
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(
@ -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 {

View file

@ -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 {

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])
}
}