Add article search feature

This commit is contained in:
Ash 2022-04-08 22:44:48 +08:00
parent aaf032332b
commit 25009e9036
8 changed files with 535 additions and 134 deletions

View File

@ -10,6 +10,198 @@ import java.util.*
@Dao
interface ArticleDao {
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId IN (
SELECT id FROM feed WHERE groupId = :groupId
)
AND isUnread = :isUnread
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByGroupIdWhenIsUnread(
accountId: Int,
text: String,
groupId: String,
isUnread: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId IN (
SELECT id FROM feed WHERE groupId = :groupId
)
AND isStarred = :isStarred
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByGroupIdWhenIsStarred(
accountId: Int,
text: String,
groupId: String,
isStarred: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId IN (
SELECT id FROM feed WHERE groupId = :groupId
)
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByGroupIdWhenAll(
accountId: Int,
text: String,
groupId: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId = :feedId
AND isUnread = :isUnread
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByFeedIdWhenIsUnread(
accountId: Int,
text: String,
feedId: String,
isUnread: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId = :feedId
AND isStarred = :isStarred
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByFeedIdWhenIsStarred(
accountId: Int,
text: String,
feedId: String,
isStarred: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND feedId = :feedId
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleByFeedIdWhenAll(
accountId: Int,
text: String,
feedId: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND isUnread = :isUnread
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleWhenIsUnread(
accountId: Int,
text: String,
isUnread: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND isStarred = :isStarred
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleWhenIsStarred(
accountId: Int,
text: String,
isStarred: Boolean,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND (
title LIKE '%' || :text || '%'
OR shortDescription LIKE '%' || :text || '%'
OR fullContent LIKE '%' || :text || '%'
)
ORDER BY date DESC
"""
)
fun searchArticleWhenAll(
accountId: Int,
text: String,
): PagingSource<Int, ArticleWithFeed>
@Query(
"""
UPDATE article SET isUnread = :isUnread
@ -92,64 +284,6 @@ interface ArticleDao {
)
suspend fun deleteByGroupId(accountId: Int, groupId: String)
@Transaction
@Query(
"""
SELECT * FROM article
WHERE accountId = :accountId
AND (
title LIKE :keyword
OR rawDescription LIKE :keyword
OR fullContent LIKE :keyword
)
ORDER BY date DESC
"""
)
fun searchArticleWithFeedWhenIsAll(
accountId: Int,
keyword: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE isUnread = :isUnread
AND accountId = :accountId
AND (
title LIKE :keyword
OR rawDescription LIKE :keyword
OR fullContent LIKE :keyword
)
ORDER BY date DESC
"""
)
fun searchArticleWithFeedWhenIsUnread(
accountId: Int,
isUnread: Boolean,
keyword: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""
SELECT * FROM article
WHERE isStarred = :isStarred
AND accountId = :accountId
AND (
title LIKE :keyword
OR rawDescription LIKE :keyword
OR fullContent LIKE :keyword
)
ORDER BY date DESC
"""
)
fun searchArticleWithFeedWhenIsStarred(
accountId: Int,
isStarred: Boolean,
keyword: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@Query(
"""

View File

@ -166,6 +166,41 @@ abstract class AbstractRssRepository constructor(
suspend fun groupMoveToTargetGroup(group: Group, targetGroup: Group) {
feedDao.updateTargetGroupIdByGroupId(context.currentAccountId, group.id, targetGroup.id)
}
fun searchArticles(
content: String,
groupId: String? = null,
feedId: String? = null,
isStarred: Boolean = false,
isUnread: Boolean = false,
): PagingSource<Int, ArticleWithFeed> {
val accountId = context.currentAccountId
Log.i(
"RLog",
"searchArticles: content: ${content}, accountId: ${accountId}, groupId: ${groupId}, feedId: ${feedId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
)
return when {
groupId != null -> when {
isStarred -> articleDao
.searchArticleByGroupIdWhenIsStarred(accountId, content, groupId, isStarred)
isUnread -> articleDao
.searchArticleByGroupIdWhenIsUnread(accountId, content, groupId, isUnread)
else -> articleDao.searchArticleByGroupIdWhenAll(accountId, content, groupId)
}
feedId != null -> when {
isStarred -> articleDao
.searchArticleByFeedIdWhenIsStarred(accountId, content, feedId, isStarred)
isUnread -> articleDao
.searchArticleByFeedIdWhenIsUnread(accountId, content, feedId, isUnread)
else -> articleDao.searchArticleByFeedIdWhenAll(accountId, content, feedId)
}
else -> when {
isStarred -> articleDao.searchArticleWhenIsStarred(accountId, content, isStarred)
isUnread -> articleDao.searchArticleWhenIsUnread(accountId, content, isUnread)
else -> articleDao.searchArticleWhenAll(accountId, content)
}
}
}
}
@HiltWorker

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.flow
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -18,8 +19,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -28,6 +31,8 @@ import androidx.navigation.NavHostController
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.work.WorkInfo
import com.google.accompanist.pager.PagerState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed
@ -42,6 +47,7 @@ import me.ash.reader.ui.page.home.FilterState
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class, com.google.accompanist.pager.ExperimentalPagerApi::class,
androidx.compose.ui.ExperimentalComposeUiApi::class,
)
@Composable
fun FlowPage(
@ -55,10 +61,13 @@ fun FlowPage(
onItemClick: (item: ArticleWithFeed) -> Unit = {},
) {
val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val scope = rememberCoroutineScope()
var markAsRead by remember { mutableStateOf(false) }
var onSearch by remember { mutableStateOf(false) }
val viewState = flowViewModel.viewState.collectAsStateValue()
val pagingItems = viewState.pagingData.collectAsLazyPagingItems()
var markAsRead by remember { mutableStateOf(false) }
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
@ -67,9 +76,37 @@ fun FlowPage(
}
LaunchedEffect(filterState) {
flowViewModel.dispatch(
FlowViewAction.FetchData(filterState)
)
snapshotFlow { filterState }.collect {
flowViewModel.dispatch(
FlowViewAction.FetchData(it)
)
}
}
LaunchedEffect(onSearch) {
snapshotFlow { onSearch }.collect {
if (it) {
delay(100) // ???
focusRequester.requestFocus()
} else {
keyboardController?.hide()
if (viewState.searchContent.isNotBlank()) {
flowViewModel.dispatch(FlowViewAction.InputSearchContent(""))
}
}
}
}
LaunchedEffect(viewState.listState) {
snapshotFlow { viewState.listState.firstVisibleItemIndex }.collect {
if (it > 0) {
keyboardController?.hide()
}
}
}
BackHandler(onSearch) {
onSearch = false
}
Scaffold(
@ -83,12 +120,13 @@ fun FlowPage(
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface
) {
onSearch = false
onScrollToPage(0)
}
},
actions = {
AnimatedVisibility(
visible = !filterState.filter.isStarred(),// && pagingItems.loadState.refresh is LoadState.NotLoading && pagingItems.itemCount != 0,
visible = !filterState.filter.isStarred(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
@ -104,20 +142,28 @@ fun FlowPage(
scope.launch {
viewState.listState.scrollToItem(0)
markAsRead = !markAsRead
onSearch = false
}
}
}
FeedbackIconButton(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurface,
tint = if (onSearch) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
) {
scope.launch {
viewState.listState.scrollToItem(0)
onSearch = !onSearch
}
}
}
)
},
content = {
Crossfade(targetState = pagingItems) { pagingItems ->
// if (pagingItems.loadState.source.refresh is LoadState.NotLoading && pagingItems.itemCount == 0) {
// LottieAnimation(
// modifier = Modifier
@ -126,57 +172,93 @@ fun FlowPage(
// url = "https://assets7.lottiefiles.com/packages/lf20_l4ny0jjm.json",
// )
// }
LazyColumn(
state = viewState.listState,
) {
item {
DisplayText(
modifier = Modifier.padding(start = 30.dp),
text = when {
filterState.group != null -> filterState.group.name
filterState.feed != null -> filterState.feed.name
else -> filterState.filter.getName()
},
desc = if (isSyncing) stringResource(R.string.syncing) else "",
LazyColumn(
state = viewState.listState,
) {
item {
DisplayText(
modifier = Modifier.padding(start = 30.dp),
text = when {
filterState.group != null -> filterState.group.name
filterState.feed != null -> filterState.feed.name
else -> filterState.filter.getName()
},
desc = if (isSyncing) stringResource(R.string.syncing) else "",
)
}
item {
AnimatedVisibility(
visible = markAsRead,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Spacer(modifier = Modifier.height((56 + 24 + 10).dp))
}
MarkAsReadBar(
visible = markAsRead,
absoluteY = if (isSyncing) (4 + 16 + 180).dp else 180.dp,
onDismissRequest = {
markAsRead = false
},
) {
markAsRead = false
flowViewModel.dispatch(
FlowViewAction.MarkAsRead(
groupId = filterState.group?.id,
feedId = filterState.feed?.id,
articleId = null,
markAsReadBefore = it,
)
)
}
item {
AnimatedVisibility(
visible = markAsRead,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Spacer(modifier = Modifier.height((56 + 24 + 10).dp))
}
MarkAsReadBar(
visible = markAsRead,
absoluteY = if (isSyncing) (4 + 16 + 180).dp else 180.dp,
onDismissRequest = {
markAsRead = false
},
) {
markAsRead = false
flowViewModel.dispatch(
FlowViewAction.MarkAsRead(
groupId = filterState.group?.id,
feedId = filterState.feed?.id,
articleId = null,
markAsReadBefore = it,
)
)
}
}
generateArticleList(
context = context,
pagingItems = pagingItems,
}
item {
AnimatedVisibility(
visible = onSearch,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
onItemClick(it)
SearchBar(
value = viewState.searchContent,
placeholder = when {
filterState.group != null -> stringResource(
R.string.search_for_in,
filterState.filter.getName(),
filterState.group.name
)
filterState.feed != null -> stringResource(
R.string.search_for_in,
filterState.filter.getName(),
filterState.feed.name
)
else -> stringResource(
R.string.search_for,
filterState.filter.getName()
)
},
focusRequester = focusRequester,
onValueChange = {
flowViewModel.dispatch(FlowViewAction.InputSearchContent(it))
},
onClose = {
onSearch = false
flowViewModel.dispatch(FlowViewAction.InputSearchContent(""))
}
)
Spacer(modifier = Modifier.height((56 + 24 + 10).dp))
}
item {
}
generateArticleList(
context = context,
pagingItems = pagingItems,
) {
onSearch = false
onItemClick(it)
}
item {
Spacer(modifier = Modifier.height(64.dp))
if (pagingItems.loadState.source.refresh is LoadState.NotLoading && pagingItems.itemCount != 0) {
Spacer(modifier = Modifier.height(64.dp))
if (pagingItems.loadState.source.refresh is LoadState.NotLoading && pagingItems.itemCount != 0) {
Spacer(modifier = Modifier.height(64.dp))
}
}
}
}
@ -187,7 +269,9 @@ fun FlowPage(
.height(60.dp)
.fillMaxWidth(),
filter = filterState.filter,
filterOnClick = { onFilterChange(filterState.copy(filter = it)) },
filterOnClick = {
onFilterChange(filterState.copy(filter = it))
},
)
}
)

View File

@ -35,31 +35,50 @@ class FlowViewModel @Inject constructor(
action.articleId,
action.markAsReadBefore,
)
is FlowViewAction.InputSearchContent -> inputSearchContent(action.content)
}
}
private fun fetchData(filterState: FilterState) {
viewModelScope.launch(Dispatchers.Default) {
rssRepository.get().pullImportant(filterState.filter.isStarred(), true)
.collect { importantList ->
_viewState.update {
it.copy(
filterImportant = importantList.sumOf { it.important },
private fun fetchData(filterState: FilterState? = null) {
// viewModelScope.launch(Dispatchers.Default) {
// rssRepository.get().pullImportant(filterState.filter.isStarred(), true)
// .collect { importantList ->
// _viewState.update {
// it.copy(
// filterImportant = importantList.sumOf { it.important },
// )
// }
// }
// }
if (_viewState.value.searchContent.isNotBlank()) {
_viewState.update {
it.copy(
filterState = filterState,
pagingData = Pager(PagingConfig(pageSize = 10)) {
rssRepository.get().searchArticles(
content = _viewState.value.searchContent.trim(),
groupId = _viewState.value.filterState?.group?.id,
feedId = _viewState.value.filterState?.feed?.id,
isStarred = _viewState.value.filterState?.filter?.isStarred() ?: false,
isUnread = _viewState.value.filterState?.filter?.isUnread() ?: false,
)
}
}
}
_viewState.update {
it.copy(
pagingData = Pager(PagingConfig(pageSize = 10)) {
rssRepository.get().pullArticles(
groupId = filterState.group?.id,
feedId = filterState.feed?.id,
isStarred = filterState.filter.isStarred(),
isUnread = filterState.filter.isUnread(),
)
}.flow.flowOn(Dispatchers.IO).cachedIn(viewModelScope)
)
}.flow.flowOn(Dispatchers.IO).cachedIn(viewModelScope)
)
}
} else if (filterState != null) {
_viewState.update {
it.copy(
filterState = filterState,
pagingData = Pager(PagingConfig(pageSize = 10)) {
rssRepository.get().pullArticles(
groupId = filterState.group?.id,
feedId = filterState.feed?.id,
isStarred = filterState.filter.isStarred(),
isUnread = filterState.filter.isUnread(),
)
}.flow.flowOn(Dispatchers.IO).cachedIn(viewModelScope)
)
}
}
}
@ -105,14 +124,25 @@ class FlowViewModel @Inject constructor(
)
}
}
private fun inputSearchContent(content: String) {
_viewState.update {
it.copy(
searchContent = content,
)
}
fetchData(_viewState.value.filterState)
}
}
data class ArticleViewState(
val filterState: FilterState? = null,
val filterImportant: Int = 0,
val listState: LazyListState = LazyListState(),
val isRefreshing: Boolean = false,
val pagingData: Flow<PagingData<ArticleWithFeed>> = emptyFlow(),
val syncWorkInfo: String = "",
val searchContent: String = "",
)
sealed class FlowViewAction {
@ -134,6 +164,10 @@ sealed class FlowViewAction {
val articleId: String?,
val markAsReadBefore: MarkAsReadBefore
) : FlowViewAction()
data class InputSearchContent(
val content: String,
) : FlowViewAction()
}
enum class MarkAsReadBefore {

View File

@ -14,6 +14,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -34,7 +35,9 @@ fun MarkAsReadBar(
val animated = remember { Animatable(absoluteY.value) }
LaunchedEffect(absoluteY) {
animated.animateTo(absoluteY.value, spring(stiffness = Spring.StiffnessMediumLow))
snapshotFlow { absoluteY }.collect {
animated.animateTo(it.value, spring(stiffness = Spring.StiffnessMediumLow))
}
}
AnimatedPopup(

View File

@ -0,0 +1,107 @@
package me.ash.reader.ui.page.home.flow
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.unit.dp
import me.ash.reader.R
@Composable
fun SearchBar(
modifier: Modifier = Modifier,
value: String,
placeholder: String = "",
focusRequester: FocusRequester = remember { FocusRequester() },
onValueChange: (String) -> Unit = {},
onClose: () -> Unit = {},
) {
val focusManager = LocalFocusManager.current
Surface(
modifier = Modifier
.height(56.dp)
.padding(horizontal = 24.dp)
.fillMaxWidth(),
shape = CircleShape,
tonalElevation = 3.dp
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.padding(start = 16.dp),
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.material.TextField(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.focusRequester(focusRequester),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.onSurface,
textColor = MaterialTheme.colorScheme.onSurface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
value = value,
onValueChange = { onValueChange(it) },
placeholder = {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
),
)
},
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
baselineShift = BaselineShift(0.1f)
),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
)
)
}
IconButton(onClick = { onClose() }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@ -58,6 +58,8 @@
<string name="today">今天</string>
<string name="yesterday">昨天</string>
<string name="date_at_time">%1$s %2$s</string>
<string name="search_for_in">在%1$s的 \"%2$s\" 中搜索</string>
<string name="search_for">在%1$s中搜索</string>
<string name="mark_as_read">标记为已读</string>
<string name="mark_all_as_read">全部标记为已读</string>
<string name="mark_as_unread">标记为未读</string>

View File

@ -58,6 +58,8 @@
<string name="today">Today</string>
<string name="yesterday">Yesterday</string>
<string name="date_at_time">%1$s At %2$s</string>
<string name="search_for_in">Search for %1$s Items in \"%2$s\"</string>
<string name="search_for">Search for %1$s Items</string>
<string name="mark_as_read">Mark as Read</string>
<string name="mark_all_as_read">Mark All as Read</string>
<string name="mark_as_unread">Mark as Unread</string>