mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate search functionality (resolve conflicts)
This commit is contained in:
commit
e1b59d38a6
8 changed files with 1159 additions and 177 deletions
|
|
@ -1 +0,0 @@
|
||||||
{"type":"server-started","port":65178,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65178","screen_dir":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049"}
|
|
||||||
1
.superpowers/brainstorm/17336-1773912049/.server-stopped
Normal file
1
.superpowers/brainstorm/17336-1773912049/.server-stopped
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"reason":"idle timeout","timestamp":1773914269831}
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map.html"}
|
{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map.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"}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
415
app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt
Normal file
415
app/src/test/java/com/swoosh/microblog/ui/feed/SearchTest.kt
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
package com.swoosh.microblog.ui.feed
|
||||||
|
|
||||||
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure unit tests for search deduplication logic and text highlighting.
|
||||||
|
* No Android framework dependencies needed.
|
||||||
|
*/
|
||||||
|
class SearchDeduplicationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults returns all posts when no overlap`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = null, title = "Local Draft")
|
||||||
|
)
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Remote Post")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults prefers remote when ghostId matches`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = "ghost-1", title = "Local Version", textContent = "old content")
|
||||||
|
)
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Remote Version", textContent = "new content")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("Remote Version", result[0].title)
|
||||||
|
assertEquals("new content", result[0].textContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults keeps local posts without ghostId`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = null, title = "Draft 1"),
|
||||||
|
createFeedPost(localId = 2, ghostId = null, title = "Draft 2")
|
||||||
|
)
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Published Post")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults with empty local returns only remote`() {
|
||||||
|
val local = emptyList<FeedPost>()
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Remote 1"),
|
||||||
|
createFeedPost(ghostId = "ghost-2", title = "Remote 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults with empty remote returns only local`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = null, title = "Draft 1")
|
||||||
|
)
|
||||||
|
val remote = emptyList<FeedPost>()
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("Draft 1", result[0].title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults with both empty returns empty list`() {
|
||||||
|
val result = FeedViewModel.deduplicateResults(emptyList(), emptyList())
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults removes multiple local duplicates of same ghostId`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = "ghost-1", title = "Local V1"),
|
||||||
|
createFeedPost(localId = 2, ghostId = "ghost-1", title = "Local V2")
|
||||||
|
)
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Remote Version")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("Remote Version", result[0].title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deduplicateResults handles mixed local with and without ghostIds`() {
|
||||||
|
val local = listOf(
|
||||||
|
createFeedPost(localId = 1, ghostId = null, title = "Draft"),
|
||||||
|
createFeedPost(localId = 2, ghostId = "ghost-1", title = "Local Synced")
|
||||||
|
)
|
||||||
|
val remote = listOf(
|
||||||
|
createFeedPost(ghostId = "ghost-1", title = "Remote Synced"),
|
||||||
|
createFeedPost(ghostId = "ghost-2", title = "Remote Only")
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = FeedViewModel.deduplicateResults(local, remote)
|
||||||
|
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
val titles = result.map { it.title }
|
||||||
|
assertTrue(titles.contains("Draft"))
|
||||||
|
assertTrue(titles.contains("Remote Synced"))
|
||||||
|
assertTrue(titles.contains("Remote Only"))
|
||||||
|
assertFalse(titles.contains("Local Synced"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFeedPost(
|
||||||
|
localId: Long? = null,
|
||||||
|
ghostId: String? = null,
|
||||||
|
title: String = "",
|
||||||
|
textContent: String = "",
|
||||||
|
status: String = "draft",
|
||||||
|
publishedAt: String? = null,
|
||||||
|
createdAt: String? = null
|
||||||
|
) = FeedPost(
|
||||||
|
localId = localId,
|
||||||
|
ghostId = ghostId,
|
||||||
|
title = title,
|
||||||
|
textContent = textContent,
|
||||||
|
htmlContent = null,
|
||||||
|
imageUrl = null,
|
||||||
|
linkUrl = null,
|
||||||
|
linkTitle = null,
|
||||||
|
linkDescription = null,
|
||||||
|
linkImageUrl = null,
|
||||||
|
status = status,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = null,
|
||||||
|
isLocal = localId != null,
|
||||||
|
queueStatus = QueueStatus.NONE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for text highlighting logic used in search results.
|
||||||
|
* Pure unit tests, no Android dependencies.
|
||||||
|
*/
|
||||||
|
class SearchHighlightTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with no match returns plain text`() {
|
||||||
|
val result = buildHighlightedString("Hello World", "xyz")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(0, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString highlights single match`() {
|
||||||
|
val result = buildHighlightedString("Hello World", "World")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(1, result.spanStyles.size)
|
||||||
|
assertEquals(6, result.spanStyles[0].start)
|
||||||
|
assertEquals(11, result.spanStyles[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString highlights multiple matches`() {
|
||||||
|
val result = buildHighlightedString("cat and cat and cat", "cat")
|
||||||
|
|
||||||
|
assertEquals("cat and cat and cat", result.text)
|
||||||
|
assertEquals(3, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString is case insensitive`() {
|
||||||
|
val result = buildHighlightedString("Hello HELLO hello", "hello")
|
||||||
|
|
||||||
|
assertEquals("Hello HELLO hello", result.text)
|
||||||
|
assertEquals(3, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with empty query returns plain text`() {
|
||||||
|
val result = buildHighlightedString("Hello World", "")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(0, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with blank query returns plain text`() {
|
||||||
|
val result = buildHighlightedString("Hello World", " ")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(0, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with query at start of text`() {
|
||||||
|
val result = buildHighlightedString("Hello World", "Hello")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(1, result.spanStyles.size)
|
||||||
|
assertEquals(0, result.spanStyles[0].start)
|
||||||
|
assertEquals(5, result.spanStyles[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with query at end of text`() {
|
||||||
|
val result = buildHighlightedString("Hello World", "World")
|
||||||
|
|
||||||
|
assertEquals("Hello World", result.text)
|
||||||
|
assertEquals(1, result.spanStyles.size)
|
||||||
|
assertEquals(6, result.spanStyles[0].start)
|
||||||
|
assertEquals(11, result.spanStyles[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with entire text matching`() {
|
||||||
|
val result = buildHighlightedString("hello", "hello")
|
||||||
|
|
||||||
|
assertEquals("hello", result.text)
|
||||||
|
assertEquals(1, result.spanStyles.size)
|
||||||
|
assertEquals(0, result.spanStyles[0].start)
|
||||||
|
assertEquals(5, result.spanStyles[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString preserves original case`() {
|
||||||
|
val result = buildHighlightedString("Hello WORLD", "hello")
|
||||||
|
|
||||||
|
assertEquals("Hello WORLD", result.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with overlapping potential matches`() {
|
||||||
|
val result = buildHighlightedString("aaa", "aa")
|
||||||
|
|
||||||
|
assertEquals("aaa", result.text)
|
||||||
|
// Should find first match at 0-2, then next search starts at 2
|
||||||
|
assertEquals(1, result.spanStyles.size)
|
||||||
|
assertEquals(0, result.spanStyles[0].start)
|
||||||
|
assertEquals(2, result.spanStyles[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildHighlightedString with single character query`() {
|
||||||
|
val result = buildHighlightedString("banana", "a")
|
||||||
|
|
||||||
|
assertEquals("banana", result.text)
|
||||||
|
assertEquals(3, result.spanStyles.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for SearchHistoryManager.
|
||||||
|
* Uses Robolectric for SharedPreferences access.
|
||||||
|
*/
|
||||||
|
@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
|
||||||
|
@org.robolectric.annotation.Config(
|
||||||
|
sdk = [28],
|
||||||
|
manifest = org.robolectric.annotation.Config.NONE,
|
||||||
|
application = android.app.Application::class
|
||||||
|
)
|
||||||
|
class SearchHistoryManagerTest {
|
||||||
|
|
||||||
|
private lateinit var searchHistoryManager: SearchHistoryManager
|
||||||
|
|
||||||
|
@org.junit.Before
|
||||||
|
fun setup() {
|
||||||
|
val context = org.robolectric.RuntimeEnvironment.getApplication()
|
||||||
|
context.getSharedPreferences(SearchHistoryManager.PREFS_NAME, android.content.Context.MODE_PRIVATE)
|
||||||
|
.edit().clear().apply()
|
||||||
|
searchHistoryManager = SearchHistoryManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getRecentSearches returns empty list initially`() {
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch adds query to history`() {
|
||||||
|
searchHistoryManager.addSearch("test query")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("test query", result[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch adds newest first`() {
|
||||||
|
searchHistoryManager.addSearch("first")
|
||||||
|
searchHistoryManager.addSearch("second")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals("second", result[0])
|
||||||
|
assertEquals("first", result[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch limits to MAX_HISTORY items`() {
|
||||||
|
repeat(7) { i ->
|
||||||
|
searchHistoryManager.addSearch("query $i")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(SearchHistoryManager.MAX_HISTORY, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch deduplicates same query`() {
|
||||||
|
searchHistoryManager.addSearch("test")
|
||||||
|
searchHistoryManager.addSearch("other")
|
||||||
|
searchHistoryManager.addSearch("test") // Duplicate
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals("test", result[0]) // Most recent
|
||||||
|
assertEquals("other", result[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch trims whitespace`() {
|
||||||
|
searchHistoryManager.addSearch(" test ")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("test", result[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch ignores blank queries`() {
|
||||||
|
searchHistoryManager.addSearch("")
|
||||||
|
searchHistoryManager.addSearch(" ")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeSearch removes specific query`() {
|
||||||
|
searchHistoryManager.addSearch("first")
|
||||||
|
searchHistoryManager.addSearch("second")
|
||||||
|
searchHistoryManager.addSearch("third")
|
||||||
|
|
||||||
|
searchHistoryManager.removeSearch("second")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertFalse(result.contains("second"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeSearch does nothing for nonexistent query`() {
|
||||||
|
searchHistoryManager.addSearch("first")
|
||||||
|
|
||||||
|
searchHistoryManager.removeSearch("nonexistent")
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearAll removes all searches`() {
|
||||||
|
searchHistoryManager.addSearch("first")
|
||||||
|
searchHistoryManager.addSearch("second")
|
||||||
|
|
||||||
|
searchHistoryManager.clearAll()
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search history persists across instances`() {
|
||||||
|
searchHistoryManager.addSearch("persistent query")
|
||||||
|
|
||||||
|
val context = org.robolectric.RuntimeEnvironment.getApplication()
|
||||||
|
val newManager = SearchHistoryManager(context)
|
||||||
|
val result = newManager.getRecentSearches()
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("persistent query", result[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addSearch moves existing query to front`() {
|
||||||
|
searchHistoryManager.addSearch("first")
|
||||||
|
searchHistoryManager.addSearch("second")
|
||||||
|
searchHistoryManager.addSearch("third")
|
||||||
|
searchHistoryManager.addSearch("first") // Re-add
|
||||||
|
|
||||||
|
val result = searchHistoryManager.getRecentSearches()
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertEquals("first", result[0])
|
||||||
|
assertEquals("third", result[1])
|
||||||
|
assertEquals("second", result[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue