feat(SearchPost): 吧内搜索记录

This commit is contained in:
HuanCheng65 2024-01-29 17:14:12 +08:00
parent e24649efe9
commit d86f0d9c41
No known key found for this signature in database
GPG Key ID: 5EC9DD60A32C7360
7 changed files with 342 additions and 66 deletions

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<litepal>
<dbname value="tblite" />
<version value="35" />
<version value="36" />
<list>
<mapping class="com.huanchengfly.tieba.post.models.database.Account" />
<mapping class="com.huanchengfly.tieba.post.models.database.Draft" />

View File

@ -75,9 +75,9 @@ class SearchPostActivity : BaseActivity() {
} else {
State.SEARCH
}
if (value != null) {
if (value != null && forumName != null) {
refreshLayout.autoRefresh()
SearchPostHistory(value, forumName)
SearchPostHistory(value, forumName!!)
.saveOrUpdate("content = ?", value)
}
}
@ -170,7 +170,7 @@ class SearchPostActivity : BaseActivity() {
state = State.INPUT
editText.showSoftInput()
} else {
finish()
super.onBackPressed()
}
}

View File

@ -1,44 +0,0 @@
package com.huanchengfly.tieba.post.models.database;
import org.litepal.crud.LitePalSupport;
public class SearchPostHistory extends LitePalSupport {
private int id;
private long timestamp;
private String content;
private String forumName;
public SearchPostHistory(String content, String forumName) {
this.timestamp = System.currentTimeMillis();
this.content = content;
this.forumName = forumName;
}
public int getId() {
return id;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getForumName() {
return forumName;
}
public void setForumName(String forumName) {
this.forumName = forumName;
}
}

View File

@ -0,0 +1,13 @@
package com.huanchengfly.tieba.post.models.database
import androidx.compose.runtime.Immutable
import org.litepal.crud.LitePalSupport
@Immutable
class SearchPostHistory(
val content: String,
val forumName: String,
val timestamp: Long = System.currentTimeMillis(),
) : LitePalSupport() {
val id: Long = 0
}

View File

@ -1,27 +1,38 @@
package com.huanchengfly.tieba.post.ui.page.forum.searchpost
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -38,6 +49,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
@ -45,9 +57,11 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.arch.collectPartialAsState
import com.huanchengfly.tieba.post.arch.pageViewModel
import com.huanchengfly.tieba.post.models.database.SearchPostHistory
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
import com.huanchengfly.tieba.post.ui.common.theme.compose.pullRefreshIndicator
import com.huanchengfly.tieba.post.ui.page.ProvideNavigator
@ -55,6 +69,7 @@ import com.huanchengfly.tieba.post.ui.page.destinations.ForumPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.SubPostsPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.ThreadPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.UserProfilePageDestination
import com.huanchengfly.tieba.post.ui.widgets.compose.Button
import com.huanchengfly.tieba.post.ui.widgets.compose.ClickMenu
import com.huanchengfly.tieba.post.ui.widgets.compose.ErrorScreen
import com.huanchengfly.tieba.post.ui.widgets.compose.HorizontalDivider
@ -68,9 +83,121 @@ import com.huanchengfly.tieba.post.ui.widgets.compose.rememberMenuState
import com.huanchengfly.tieba.post.ui.widgets.compose.states.StateScreen
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
private fun SearchHistoryList(
searchHistories: ImmutableList<SearchPostHistory>,
onSearchHistoryClick: (SearchPostHistory) -> Unit,
expanded: Boolean = false,
onToggleExpand: () -> Unit = {},
onDelete: (SearchPostHistory) -> Unit = {},
onClear: () -> Unit = {},
) {
val hasItem = remember(searchHistories) {
searchHistories.isNotEmpty()
}
val hasMore = remember(searchHistories) {
searchHistories.size > 6
}
val showItem = remember(expanded, hasMore, searchHistories) {
if (!expanded && hasMore) searchHistories.take(6) else searchHistories
}
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.title_search_history),
modifier = Modifier
.weight(1f),
style = MaterialTheme.typography.subtitle1
)
if (hasItem) {
Text(
text = stringResource(id = R.string.button_clear_all),
modifier = Modifier.clickable(onClick = onClear),
style = MaterialTheme.typography.button
)
}
}
FlowRow(
modifier = Modifier
.padding(horizontal = 16.dp)
.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
showItem.fastForEach { searchHistory ->
Box(
modifier = Modifier
.padding(bottom = 8.dp)
.clip(RoundedCornerShape(100))
.combinedClickable(
onClick = { onSearchHistoryClick(searchHistory) },
onLongClick = { onDelete(searchHistory) }
)
.background(ExtendedTheme.colors.chip)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = searchHistory.content
)
}
}
}
if (hasMore) {
Button(
onClick = onToggleExpand,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
backgroundColor = Color.Transparent,
contentColor = ExtendedTheme.colors.text
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Text(
text = stringResource(
id = if (expanded) R.string.button_expand_less_history else R.string.button_expand_more_history
),
style = MaterialTheme.typography.button,
fontWeight = FontWeight.Bold
)
}
}
}
if (!hasItem) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(id = R.string.tip_empty),
color = ExtendedTheme.colors.textDisabled,
fontSize = 16.sp
)
}
}
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Destination
@Composable
@ -78,7 +205,9 @@ fun ForumSearchPostPage(
forumName: String,
forumId: Long,
navigator: DestinationsNavigator,
viewModel: ForumSearchPostViewModel = pageViewModel(),
viewModel: ForumSearchPostViewModel = pageViewModel<ForumSearchPostUiIntent, ForumSearchPostViewModel>(
listOf(ForumSearchPostUiIntent.Init)
),
) {
val context = LocalContext.current
val currentKeyword by viewModel.uiState.collectPartialAsState(
@ -117,6 +246,11 @@ fun ForumSearchPostPage(
prop1 = ForumSearchPostUiState::hasMore,
initial = true
)
val searchHistories by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::searchHistories,
initial = persistentListOf()
)
val isEmpty by remember {
derivedStateOf { data.isEmpty() }
}
@ -453,6 +587,27 @@ fun ForumSearchPostPage(
)
}
}
} else {
SearchHistoryList(
searchHistories = searchHistories,
onSearchHistoryClick = {
viewModel.send(
ForumSearchPostUiIntent.Refresh(
it.content,
forumName,
forumId,
currentSortType,
currentFilterType
)
)
},
onDelete = {
viewModel.send(ForumSearchPostUiIntent.DeleteHistory(it.id))
},
onClear = {
viewModel.send(ForumSearchPostUiIntent.ClearHistory)
}
)
}
}
}

View File

@ -1,8 +1,12 @@
package com.huanchengfly.tieba.post.ui.page.forum.searchpost
import com.huanchengfly.tieba.post.App
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.api.TiebaApi
import com.huanchengfly.tieba.post.api.models.SearchThreadBean
import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage
import com.huanchengfly.tieba.post.arch.BaseViewModel
import com.huanchengfly.tieba.post.arch.CommonUiEvent
import com.huanchengfly.tieba.post.arch.ImmutableHolder
import com.huanchengfly.tieba.post.arch.PartialChange
import com.huanchengfly.tieba.post.arch.PartialChangeProducer
@ -10,18 +14,29 @@ import com.huanchengfly.tieba.post.arch.UiEvent
import com.huanchengfly.tieba.post.arch.UiIntent
import com.huanchengfly.tieba.post.arch.UiState
import com.huanchengfly.tieba.post.arch.wrapImmutable
import com.huanchengfly.tieba.post.models.database.SearchPostHistory
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.litepal.LitePal
import org.litepal.extension.delete
import org.litepal.extension.deleteAll
import org.litepal.extension.find
import javax.inject.Inject
@HiltViewModel
@ -32,20 +47,69 @@ class ForumSearchPostViewModel @Inject constructor() :
override fun createPartialChangeProducer(): PartialChangeProducer<ForumSearchPostUiIntent, ForumSearchPostPartialChange, ForumSearchPostUiState> =
ForumSearchPostPartialChangeProducer
override fun dispatchEvent(partialChange: ForumSearchPostPartialChange): UiEvent? =
when (partialChange) {
is ForumSearchPostPartialChange.DeleteHistory.Failure -> CommonUiEvent.Toast(
App.INSTANCE.getString(
R.string.toast_delete_failure,
partialChange.error.getErrorMessage()
)
)
is ForumSearchPostPartialChange.ClearHistory.Success -> CommonUiEvent.Toast(
App.INSTANCE.getString(R.string.toast_clear_success)
)
is ForumSearchPostPartialChange.ClearHistory.Failure -> CommonUiEvent.Toast(
App.INSTANCE.getString(
R.string.toast_clear_failure,
partialChange.error.getErrorMessage()
)
)
else -> null
}
private object ForumSearchPostPartialChangeProducer :
PartialChangeProducer<ForumSearchPostUiIntent, ForumSearchPostPartialChange, ForumSearchPostUiState> {
@OptIn(ExperimentalCoroutinesApi::class)
override fun toPartialChangeFlow(intentFlow: Flow<ForumSearchPostUiIntent>): Flow<ForumSearchPostPartialChange> =
merge(
intentFlow.filterIsInstance<ForumSearchPostUiIntent.Init>()
.flatMapConcat { produceInitPartialChange() },
intentFlow.filterIsInstance<ForumSearchPostUiIntent.Refresh>()
.flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<ForumSearchPostUiIntent.LoadMore>()
.flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<ForumSearchPostUiIntent.DeleteHistory>()
.flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<ForumSearchPostUiIntent.ClearHistory>()
.flatMapConcat { produceClearHistoryPartialChange() },
)
private fun produceInitPartialChange(): Flow<ForumSearchPostPartialChange.Init> =
flow<ForumSearchPostPartialChange.Init> {
val searchHistories = LitePal
.order("timestamp DESC")
.find<SearchPostHistory>()
emit(ForumSearchPostPartialChange.Init.Success(searchHistories))
}.catch {
emit(ForumSearchPostPartialChange.Init.Failure(it))
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun ForumSearchPostUiIntent.Refresh.producePartialChange(): Flow<ForumSearchPostPartialChange.Refresh> =
TiebaApi.getInstance()
.searchPostFlow(keyword, forumName, forumId, sortType, filterType)
flowOf(keyword.trim())
.filter { it.isNotBlank() }
.onEach {
runCatching {
SearchPostHistory(it, forumName).saveOrUpdate("content = ?", it)
}
}
.flatMapConcat {
TiebaApi.getInstance()
.searchPostFlow(it, forumName, forumId, sortType, filterType)
}
.map<SearchThreadBean, ForumSearchPostPartialChange.Refresh> {
val postList = it.data.postList.toImmutableList()
ForumSearchPostPartialChange.Refresh.Success(
@ -60,12 +124,14 @@ class ForumSearchPostViewModel @Inject constructor() :
emit(
ForumSearchPostPartialChange.Refresh.Start(
keyword,
forumName,
sortType,
filterType
)
)
}
.catch { emit(ForumSearchPostPartialChange.Refresh.Failure(it)) }
.flowOn(Dispatchers.IO)
private fun ForumSearchPostUiIntent.LoadMore.producePartialChange(): Flow<ForumSearchPostPartialChange.LoadMore> =
TiebaApi.getInstance()
@ -83,10 +149,28 @@ class ForumSearchPostViewModel @Inject constructor() :
}
.onStart { emit(ForumSearchPostPartialChange.LoadMore.Start) }
.catch { emit(ForumSearchPostPartialChange.LoadMore.Failure(it)) }
private fun ForumSearchPostUiIntent.DeleteHistory.producePartialChange(): Flow<ForumSearchPostPartialChange.DeleteHistory> =
flow<ForumSearchPostPartialChange.DeleteHistory> {
LitePal.delete<SearchPostHistory>(id)
emit(ForumSearchPostPartialChange.DeleteHistory.Success(id))
}.catch {
emit(ForumSearchPostPartialChange.DeleteHistory.Failure(it))
}
private fun produceClearHistoryPartialChange(): Flow<ForumSearchPostPartialChange.ClearHistory> =
flow<ForumSearchPostPartialChange.ClearHistory> {
LitePal.deleteAll<SearchPostHistory>()
emit(ForumSearchPostPartialChange.ClearHistory.Success)
}.catch {
emit(ForumSearchPostPartialChange.ClearHistory.Failure(it))
}
}
}
sealed interface ForumSearchPostUiIntent : UiIntent {
data object Init : ForumSearchPostUiIntent
data class Refresh(
val keyword: String,
val forumName: String,
@ -103,20 +187,52 @@ sealed interface ForumSearchPostUiIntent : UiIntent {
val sortType: Int,
val filterType: Int,
) : ForumSearchPostUiIntent
data class DeleteHistory(val id: Long) : ForumSearchPostUiIntent
data object ClearHistory : ForumSearchPostUiIntent
}
sealed interface ForumSearchPostPartialChange : PartialChange<ForumSearchPostUiState> {
sealed class Init : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Success -> oldState.copy(
searchHistories = searchHistories.toImmutableList()
)
is Failure -> oldState
}
data class Success(
val searchHistories: List<SearchPostHistory>,
) : Init()
data class Failure(
val error: Throwable,
) : Init()
}
sealed class Refresh : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Start -> oldState.copy(
isRefreshing = true,
isLoadingMore = false,
error = null,
keyword = keyword,
sortType = sortType,
filterType = filterType,
)
is Start -> {
val newSearchHistories = (oldState.searchHistories
.filterNot { it.content == keyword } + SearchPostHistory(
keyword,
forumName
))
.sortedByDescending { it.timestamp }
oldState.copy(
isRefreshing = true,
isLoadingMore = false,
error = null,
searchHistories = newSearchHistories.toImmutableList(),
keyword = keyword,
sortType = sortType,
filterType = filterType,
)
}
is Success -> oldState.copy(
isRefreshing = false,
@ -139,6 +255,7 @@ sealed interface ForumSearchPostPartialChange : PartialChange<ForumSearchPostUiS
data class Start(
val keyword: String,
val forumName: String,
val sortType: Int,
val filterType: Int,
) : Refresh()
@ -196,12 +313,48 @@ sealed interface ForumSearchPostPartialChange : PartialChange<ForumSearchPostUiS
val error: Throwable,
) : LoadMore()
}
sealed class DeleteHistory : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Success -> oldState.copy(
searchHistories = oldState.searchHistories.filterNot { it.id == id }
.toImmutableList()
)
is Failure -> oldState
}
data class Success(val id: Long) : DeleteHistory()
data class Failure(
val error: Throwable,
) : DeleteHistory()
}
sealed class ClearHistory : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Success -> oldState.copy(
searchHistories = persistentListOf()
)
is Failure -> oldState
}
data object Success : ClearHistory()
data class Failure(
val error: Throwable,
) : ClearHistory()
}
}
data class ForumSearchPostUiState(
val isRefreshing: Boolean = true,
val isLoadingMore: Boolean = false,
val error: ImmutableHolder<Throwable>? = null,
val searchHistories: ImmutableList<SearchPostHistory> = persistentListOf(),
val currentPage: Int = 1,
val hasMore: Boolean = true,
val keyword: String = "",

View File

@ -104,16 +104,15 @@ class SearchViewModel :
}.flowOn(Dispatchers.IO)
private fun SearchUiIntent.SubmitKeyword.producePartialChange() =
flowOf(SearchPartialChange.SubmitKeyword(keyword.trim()))
flowOf(keyword.trim())
.onEach {
runCatching {
val trimKeyword = keyword.trim()
if (trimKeyword.isNotBlank()) SearchHistory(trimKeyword).saveOrUpdate(
"content = ?",
trimKeyword
)
if (it.isNotBlank()) {
runCatching {
SearchHistory(it).saveOrUpdate("content = ?", it)
}
}
}
.map { SearchPartialChange.SubmitKeyword(it) }
private fun SearchUiIntent.KeywordInputChanged.producePartialChange() =
if (keyword.isNotBlank()) {