From d86f0d9c41d23c3a12ce4ab45fc57b5cffb5f7fe Mon Sep 17 00:00:00 2001 From: HuanCheng65 <22636177+HuanCheng65@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:14:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(SearchPost):=20=E5=90=A7=E5=86=85=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/assets/litepal.xml | 2 +- .../post/activities/SearchPostActivity.kt | 6 +- .../models/database/SearchPostHistory.java | 44 ----- .../post/models/database/SearchPostHistory.kt | 13 ++ .../forum/searchpost/ForumSearchPostPage.kt | 157 +++++++++++++++- .../searchpost/ForumSearchPostViewModel.kt | 173 +++++++++++++++++- .../post/ui/page/search/SearchViewModel.kt | 13 +- 7 files changed, 342 insertions(+), 66 deletions(-) delete mode 100644 app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.java create mode 100644 app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.kt diff --git a/app/src/main/assets/litepal.xml b/app/src/main/assets/litepal.xml index 88cee974..c021d125 100644 --- a/app/src/main/assets/litepal.xml +++ b/app/src/main/assets/litepal.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/java/com/huanchengfly/tieba/post/activities/SearchPostActivity.kt b/app/src/main/java/com/huanchengfly/tieba/post/activities/SearchPostActivity.kt index 7711b412..086d032c 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/activities/SearchPostActivity.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/activities/SearchPostActivity.kt @@ -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() } } diff --git a/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.java b/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.java deleted file mode 100644 index 2b77d4da..00000000 --- a/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.kt b/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.kt new file mode 100644 index 00000000..38b8985c --- /dev/null +++ b/app/src/main/java/com/huanchengfly/tieba/post/models/database/SearchPostHistory.kt @@ -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 +} diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt index 31403da8..ed2b8caa 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt @@ -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, + 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( + 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) + } + ) } } } diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt index ed178292..16e5c20f 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt @@ -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 = 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 { @OptIn(ExperimentalCoroutinesApi::class) override fun toPartialChangeFlow(intentFlow: Flow): Flow = merge( + intentFlow.filterIsInstance() + .flatMapConcat { produceInitPartialChange() }, intentFlow.filterIsInstance() .flatMapConcat { it.producePartialChange() }, intentFlow.filterIsInstance() .flatMapConcat { it.producePartialChange() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.producePartialChange() }, + intentFlow.filterIsInstance() + .flatMapConcat { produceClearHistoryPartialChange() }, ) + private fun produceInitPartialChange(): Flow = + flow { + val searchHistories = LitePal + .order("timestamp DESC") + .find() + emit(ForumSearchPostPartialChange.Init.Success(searchHistories)) + }.catch { + emit(ForumSearchPostPartialChange.Init.Failure(it)) + } + + @OptIn(ExperimentalCoroutinesApi::class) private fun ForumSearchPostUiIntent.Refresh.producePartialChange(): Flow = - 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 { 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 = 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 = + flow { + LitePal.delete(id) + emit(ForumSearchPostPartialChange.DeleteHistory.Success(id)) + }.catch { + emit(ForumSearchPostPartialChange.DeleteHistory.Failure(it)) + } + + private fun produceClearHistoryPartialChange(): Flow = + flow { + LitePal.deleteAll() + 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 { + 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, + ) : 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 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? = null, + val searchHistories: ImmutableList = persistentListOf(), val currentPage: Int = 1, val hasMore: Boolean = true, val keyword: String = "", diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt index 9ca31d91..08bf6c84 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt @@ -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()) {