feat: 首页显示最近浏览的吧

This commit is contained in:
HuanCheng65 2024-02-01 14:09:50 +08:00
parent c7164e2c71
commit 42c640cc8f
No known key found for this signature in database
GPG Key ID: 5EC9DD60A32C7360
11 changed files with 255 additions and 51 deletions

View File

@ -9,6 +9,7 @@ import okhttp3.Response
import java.io.IOException
import java.net.SocketException
import java.net.SocketTimeoutException
import javax.net.ssl.SSLHandshakeException
object ConnectivityInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
@ -17,7 +18,7 @@ object ConnectivityInterceptor : Interceptor {
val exception = response.exceptionOrNull()
return when {
(exception is SocketTimeoutException || exception is SocketException) && isNetworkConnected() -> throw NoConnectivityException(
(exception is SocketTimeoutException || exception is SocketException || exception is SSLHandshakeException) && isNetworkConnected() -> throw NoConnectivityException(
App.INSTANCE.getString(R.string.connectivity_timeout)
)

View File

@ -0,0 +1,10 @@
package com.huanchengfly.tieba.post.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ForumHistoryExtra(
@SerialName("forum_id")
val forumId: Long,
)

View File

@ -1,7 +1,9 @@
package com.huanchengfly.tieba.post.models.database
import androidx.compose.runtime.Immutable
import org.litepal.crud.LitePalSupport
@Immutable
data class History(
val title: String = "",
val data: String = "",

View File

@ -21,7 +21,7 @@ import com.huanchengfly.tieba.post.ui.common.theme.utils.ThemeUtils
import com.huanchengfly.tieba.post.utils.AccountUtil
import com.huanchengfly.tieba.post.utils.ProgressListener
import com.huanchengfly.tieba.post.utils.SingleAccountSigner
import com.huanchengfly.tieba.post.utils.addFlag
import com.huanchengfly.tieba.post.utils.extension.addFlag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@ -92,6 +92,7 @@ import com.huanchengfly.tieba.post.arch.onEvent
import com.huanchengfly.tieba.post.arch.pageViewModel
import com.huanchengfly.tieba.post.dataStore
import com.huanchengfly.tieba.post.getInt
import com.huanchengfly.tieba.post.models.ForumHistoryExtra
import com.huanchengfly.tieba.post.models.database.History
import com.huanchengfly.tieba.post.toastShort
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
@ -133,6 +134,8 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
@ -474,7 +477,8 @@ fun ForumPage(
timestamp = System.currentTimeMillis(),
avatar = forum.avatar,
type = HistoryUtil.TYPE_FORUM,
data = forum.name
data = forum.name,
extras = Json.encodeToString(ForumHistoryExtra(forum.id))
),
true
)

View File

@ -2,11 +2,15 @@ package com.huanchengfly.tieba.post.ui.page.main.home
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -17,19 +21,21 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ViewAgenda
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Search
@ -37,6 +43,7 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -47,6 +54,7 @@ import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@ -83,6 +91,7 @@ import com.huanchengfly.tieba.post.ui.widgets.compose.ErrorScreen
import com.huanchengfly.tieba.post.ui.widgets.compose.LongClickMenu
import com.huanchengfly.tieba.post.ui.widgets.compose.MenuState
import com.huanchengfly.tieba.post.ui.widgets.compose.MyLazyVerticalGrid
import com.huanchengfly.tieba.post.ui.widgets.compose.MyScaffold
import com.huanchengfly.tieba.post.ui.widgets.compose.TextButton
import com.huanchengfly.tieba.post.ui.widgets.compose.TipScreen
import com.huanchengfly.tieba.post.ui.widgets.compose.Toolbar
@ -394,6 +403,14 @@ fun HomePage(
prop1 = HomeUiState::topForums,
initial = persistentListOf()
)
val historyForums by viewModel.uiState.collectPartialAsState(
prop1 = HomeUiState::historyForums,
initial = persistentListOf()
)
val showHistoryForum by viewModel.uiState.collectPartialAsState(
prop1 = HomeUiState::showHistoryForum,
initial = true
)
val error by viewModel.uiState.collectPartialAsState(
prop1 = HomeUiState::error,
initial = null
@ -401,6 +418,7 @@ fun HomePage(
val isLoggedIn = remember(account) { account != null }
val isEmpty by remember { derivedStateOf { forums.isEmpty() } }
val hasTopForum by remember { derivedStateOf { topForums.isNotEmpty() } }
val hasHistoryForum by remember { derivedStateOf { historyForums.isNotEmpty() } }
var listSingle by remember { mutableStateOf(context.appPreferences.listSingle) }
val isError by remember { derivedStateOf { error != null } }
val gridCells by remember { derivedStateOf { getGridCells(context, listSingle) } }
@ -429,7 +447,11 @@ fun HomePage(
)
}
Scaffold(
LaunchedEffect(Unit) {
if (viewModel.initialized) viewModel.send(HomeUiIntent.RefreshHistory)
}
MyScaffold(
backgroundColor = Color.Transparent,
topBar = {
Toolbar(
@ -464,7 +486,7 @@ fun HomePage(
.padding(contentPaddings)
) {
Column {
SearchBox(modifier = Modifier.padding(bottom = 12.dp)) {
SearchBox(modifier = Modifier.padding(bottom = 4.dp)) {
navigator.navigate(SearchPageDestination)
}
StateScreen(
@ -496,14 +518,103 @@ fun HomePage(
contentPadding = PaddingValues(bottom = 12.dp),
modifier = Modifier.fillMaxSize(),
) {
if (hasHistoryForum) {
item(key = "HistoryForums", span = { GridItemSpan(maxLineSpan) }) {
val rotate by animateFloatAsState(
targetValue = if (showHistoryForum) 90f else 0f,
label = "rotate"
)
Column {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
viewModel.send(
HomeUiIntent.ToggleHistory(
showHistoryForum
)
)
}
.padding(vertical = 8.dp)
.padding(end = 16.dp)
) {
Header(
text = stringResource(id = R.string.title_history_forum),
invert = false
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = stringResource(id = R.string.desc_show),
modifier = Modifier
.size(24.dp)
.rotate(rotate)
)
}
AnimatedVisibility(visible = showHistoryForum) {
LazyRow(
contentPadding = PaddingValues(bottom = 8.dp),
) {
item(key = "Spacer1") {
Spacer(modifier = Modifier.width(12.dp))
}
items(
historyForums,
key = { it.data }
) {
Row(
modifier = Modifier
.padding(horizontal = 4.dp)
.height(IntrinsicSize.Min)
.clip(RoundedCornerShape(100))
.background(color = ExtendedTheme.colors.chip)
.clickable {
navigator.navigate(
ForumPageDestination(
it.data
)
)
}
.padding(4.dp),
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Avatar(
data = it.avatar,
contentDescription = null,
size = 24.dp,
shape = CircleShape
)
Text(
text = it.title,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 4.dp)
)
}
}
item(key = "Spacer2") {
Spacer(modifier = Modifier.width(12.dp))
}
}
}
}
}
}
if (hasTopForum) {
item(key = "TopForumHeader", span = { GridItemSpan(maxLineSpan) }) {
Column {
Column(
modifier = Modifier.padding(vertical = 8.dp)
) {
Header(
text = stringResource(id = R.string.title_top_forum),
invert = true
)
Spacer(modifier = Modifier.height(8.dp))
}
}
items(
@ -529,11 +640,11 @@ fun HomePage(
isTopForum = true
)
}
}
if (hasHistoryForum || hasTopForum) {
item(key = "ForumHeader", span = { GridItemSpan(maxLineSpan) }) {
Column(
modifier = Modifier.padding(
vertical = 8.dp
)
modifier = Modifier.padding(vertical = 8.dp)
) {
Header(text = stringResource(id = R.string.forum_list_title))
}
@ -588,7 +699,9 @@ private fun HomePageSkeletonScreen(
.fillMaxSize(),
) {
item(key = "TopForumHeaderPlaceholder", span = { GridItemSpan(maxLineSpan) }) {
Column {
Column(
modifier = Modifier.padding(vertical = 8.dp)
) {
Header(
text = stringResource(id = R.string.title_top_forum),
modifier = Modifier.placeholder(
@ -597,7 +710,6 @@ private fun HomePageSkeletonScreen(
),
invert = true
)
Spacer(modifier = Modifier.height(8.dp))
}
}
items(6, key = { "TopPlaceholder$it" }) {

View File

@ -4,7 +4,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.huanchengfly.tieba.post.api.TiebaApi
import com.huanchengfly.tieba.post.api.models.CommonResponse
import com.huanchengfly.tieba.post.api.models.protos.forumRecommend.ForumRecommendResponse
import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage
import com.huanchengfly.tieba.post.arch.BaseViewModel
import com.huanchengfly.tieba.post.arch.CommonUiEvent
@ -13,8 +12,10 @@ import com.huanchengfly.tieba.post.arch.PartialChangeProducer
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.models.database.History
import com.huanchengfly.tieba.post.models.database.TopForum
import com.huanchengfly.tieba.post.utils.AccountUtil
import com.huanchengfly.tieba.post.utils.HistoryUtil
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -25,10 +26,12 @@ import kotlinx.coroutines.flow.catch
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.onStart
import kotlinx.coroutines.flow.zip
import org.litepal.LitePal
@Stable
@ -52,18 +55,25 @@ class HomeViewModel : BaseViewModel<HomeUiIntent, HomePartialChange, HomeUiState
return merge(
intentFlow.filterIsInstance<HomeUiIntent.Refresh>()
.flatMapConcat { produceRefreshPartialChangeFlow() },
intentFlow.filterIsInstance<HomeUiIntent.RefreshHistory>()
.flatMapConcat { produceRefreshHistoryPartialChangeFlow() },
intentFlow.filterIsInstance<HomeUiIntent.TopForums.Delete>()
.flatMapConcat { it.toPartialChangeFlow() },
intentFlow.filterIsInstance<HomeUiIntent.TopForums.Add>()
.flatMapConcat { it.toPartialChangeFlow() },
intentFlow.filterIsInstance<HomeUiIntent.Unfollow>()
.flatMapConcat { it.toPartialChangeFlow() },
intentFlow.filterIsInstance<HomeUiIntent.ToggleHistory>()
.flatMapConcat { it.toPartialChangeFlow() }
)
}
private fun produceRefreshPartialChangeFlow() =
@Suppress("USELESS_CAST")
private fun produceRefreshPartialChangeFlow(): Flow<HomePartialChange.Refresh> =
HistoryUtil.getFlow(HistoryUtil.TYPE_FORUM, 0)
.zip(
TiebaApi.getInstance().forumRecommendNewFlow()
.map<ForumRecommendResponse, HomePartialChange.Refresh> { forumRecommend ->
) { historyForums, forumRecommend ->
val forums = forumRecommend.data_?.like_forum?.map {
HomeUiState.Forum(
it.avatar,
@ -76,11 +86,21 @@ class HomeViewModel : BaseViewModel<HomeUiIntent, HomePartialChange, HomeUiState
val topForums = mutableListOf<HomeUiState.Forum>()
val topForumsDB = LitePal.findAll(TopForum::class.java).map { it.forumId }
topForums.addAll(forums.filter { topForumsDB.contains(it.forumId) })
HomePartialChange.Refresh.Success(forums, topForums)
HomePartialChange.Refresh.Success(
forums,
topForums,
historyForums
) as HomePartialChange.Refresh
}
.onStart { emit(HomePartialChange.Refresh.Start) }
.catch { emit(HomePartialChange.Refresh.Failure(it)) }
@Suppress("USELESS_CAST")
private fun produceRefreshHistoryPartialChangeFlow(): Flow<HomePartialChange.RefreshHistory> =
HistoryUtil.getFlow(HistoryUtil.TYPE_FORUM, 0)
.map { HomePartialChange.RefreshHistory.Success(it) as HomePartialChange.RefreshHistory }
.catch { emit(HomePartialChange.RefreshHistory.Failure(it)) }
private fun HomeUiIntent.TopForums.Delete.toPartialChangeFlow() =
flow {
val deletedRows = LitePal.deleteAll(TopForum::class.java, "forumId = ?", forumId)
@ -110,11 +130,16 @@ class HomeViewModel : BaseViewModel<HomeUiIntent, HomePartialChange, HomeUiState
HomePartialChange.Unfollow.Success(forumId)
}
.catch { emit(HomePartialChange.Unfollow.Failure(it.getErrorMessage())) }
private fun HomeUiIntent.ToggleHistory.toPartialChangeFlow() =
flowOf(HomePartialChange.ToggleHistory(!currentShow))
}
}
sealed interface HomeUiIntent : UiIntent {
object Refresh : HomeUiIntent
data object Refresh : HomeUiIntent
data object RefreshHistory : HomeUiIntent
data class Unfollow(val forumId: String, val forumName: String) : HomeUiIntent
@ -123,6 +148,8 @@ sealed interface HomeUiIntent : UiIntent {
data class Add(val forum: HomeUiState.Forum) : TopForums
}
data class ToggleHistory(val currentShow: Boolean) : HomeUiIntent
}
sealed interface HomePartialChange : PartialChange<HomeUiState> {
@ -153,6 +180,7 @@ sealed interface HomePartialChange : PartialChange<HomeUiState> {
isLoading = false,
forums = forums.toImmutableList(),
topForums = topForums.toImmutableList(),
historyForums = historyForums.toImmutableList(),
error = null
)
@ -160,18 +188,38 @@ sealed interface HomePartialChange : PartialChange<HomeUiState> {
Start -> oldState.copy(isLoading = true)
}
object Start : Refresh()
data object Start : Refresh()
data class Success(
val forums: List<HomeUiState.Forum>,
val topForums: List<HomeUiState.Forum>,
val historyForums: List<History>,
) : Refresh()
data class Failure(
val error: Throwable
val error: Throwable,
) : Refresh()
}
sealed class RefreshHistory : HomePartialChange {
override fun reduce(oldState: HomeUiState): HomeUiState =
when (this) {
is Success -> oldState.copy(
historyForums = historyForums.toImmutableList(),
)
else -> oldState
}
data class Success(
val historyForums: List<History>,
) : RefreshHistory()
data class Failure(
val error: Throwable,
) : RefreshHistory()
}
sealed interface TopForums : HomePartialChange {
sealed interface Delete : HomePartialChange {
override fun reduce(oldState: HomeUiState): HomeUiState =
@ -207,6 +255,11 @@ sealed interface HomePartialChange : PartialChange<HomeUiState> {
data class Failure(val errorMessage: String) : Add
}
}
data class ToggleHistory(val show: Boolean) : HomePartialChange {
override fun reduce(oldState: HomeUiState): HomeUiState =
oldState.copy(showHistoryForum = show)
}
}
@Immutable
@ -214,6 +267,8 @@ data class HomeUiState(
val isLoading: Boolean = true,
val forums: ImmutableList<Forum> = persistentListOf(),
val topForums: ImmutableList<Forum> = persistentListOf(),
val historyForums: ImmutableList<History> = persistentListOf(),
val showHistoryForum: Boolean = true,
val error: Throwable? = null,
) : UiState {
@Immutable

View File

@ -1,15 +1,13 @@
package com.huanchengfly.tieba.post.utils
import com.huanchengfly.tieba.post.models.database.History
import kotlinx.coroutines.Dispatchers
import com.huanchengfly.tieba.post.utils.extension.findFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.litepal.LitePal.deleteAll
import org.litepal.LitePal.order
import org.litepal.LitePal.where
import org.litepal.LitePal
import org.litepal.crud.async.FindMultiExecutor
import org.litepal.extension.deleteAll
import org.litepal.extension.find
import org.litepal.extension.findAsync
import org.litepal.extension.findFirstAsync
object HistoryUtil {
@ -17,7 +15,7 @@ object HistoryUtil {
const val TYPE_FORUM = 1
const val TYPE_THREAD = 2
fun deleteAll() {
deleteAll(History::class.java)
LitePal.deleteAll<History>()
}
@JvmOverloads
@ -30,43 +28,33 @@ object HistoryUtil {
}
val all: List<History>
get() = order("timestamp desc, count desc").limit(100).find(
History::class.java
)
get() = LitePal.order("timestamp desc, count desc").limit(100).find<History>()
fun getAll(type: Int): List<History> {
return order("timestamp desc, count desc").where("type = ?", type.toString())
return LitePal.order("timestamp desc, count desc").where("type = ?", type.toString())
.limit(PAGE_SIZE)
.find(
History::class.java
)
.find<History>()
}
fun getAllAsync(type: Int): FindMultiExecutor<History> {
return order("timestamp desc, count desc").where("type = ?", type.toString())
return LitePal.order("timestamp desc, count desc").where("type = ?", type.toString())
.limit(PAGE_SIZE)
.findAsync(
History::class.java
)
.findAsync<History>()
}
fun getFlow(
type: Int,
page: Int
): Flow<List<History>> {
return flow<List<History>> {
emit(
where("type = ?", "$type")
return LitePal.where("type = ?", "$type")
.order("timestamp desc, count desc")
.limit(PAGE_SIZE)
.offset(page * 100)
.find()
)
}.flowOn(Dispatchers.IO)
.findFlow()
}
private fun update(history: History): Boolean {
val historyBean = where("data = ?", history.data).findFirst(
val historyBean = LitePal.where("data = ?", history.data).findFirst(
History::class.java
)
if (historyBean != null) {
@ -87,7 +75,7 @@ object HistoryUtil {
history: History,
callback: ((Boolean) -> Unit)? = null,
) {
where("data = ?", history.data).findFirstAsync<History?>()
LitePal.where("data = ?", history.data).findFirstAsync<History?>()
.listen {
if (it == null) {
callback?.invoke(false)

View File

@ -1,4 +1,4 @@
package com.huanchengfly.tieba.post.utils
package com.huanchengfly.tieba.post.utils.extension
/**
* 添加flag

View File

@ -0,0 +1,31 @@
package com.huanchengfly.tieba.post.utils.extension
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.litepal.FluentQuery
import org.litepal.LitePal
import org.litepal.extension.find
import org.litepal.extension.findAll
inline fun <reified T> LitePal.findAllFlow(vararg ids: Long): Flow<List<T>> =
flow {
emit(
findAll<T>(*ids)
)
}.flowOn(Dispatchers.IO)
inline fun <reified T> LitePal.findAllFlow(isEager: Boolean, vararg ids: Long): Flow<List<T>> =
flow {
emit(
findAll<T>(isEager, *ids)
)
}.flowOn(Dispatchers.IO)
inline fun <reified T> FluentQuery.findFlow(): Flow<List<T>> =
flow {
emit(
find<T>()
)
}.flowOn(Dispatchers.IO)

View File

@ -514,4 +514,5 @@
<string name="summary_photo_picker_not_supported">当前 Android 版本不支持照片选择器</string>
<string name="summary_do_not_use_photo_picker">将使用 App 内置的照片选择器</string>
<string name="summary_use_photo_picker">将使用原生的照片选择器</string>
<string name="desc_show">显示</string>
</resources>