refactor(ui): refactor `ReadingPage` & `ReadingViewModel` (#559)

* refactor(ui): refactor `ReadingPage` & `ReadingViewModel`

* fix(ui): disable action when next article unavailable
This commit is contained in:
junkfood 2024-01-22 17:26:31 +08:00 committed by GitHub
parent de5e6cf0f8
commit 610895fcdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 134 deletions

View File

@ -27,7 +27,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -114,3 +116,62 @@ public fun materialSharedAxisXOut(
easing = FastOutLinearInEasing easing = FastOutLinearInEasing
) )
) )
/**
* [materialSharedAxisY] allows to switch a layout with shared Y-axis transition.
*
*/
@OptIn(ExperimentalAnimationApi::class)
public fun materialSharedAxisY(
initialOffsetY: (fullWidth: Int) -> Int,
targetOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ContentTransform = ContentTransform(materialSharedAxisYIn(
initialOffsetY = initialOffsetY,
durationMillis = durationMillis
), materialSharedAxisYOut(
targetOffsetY = targetOffsetY,
durationMillis = durationMillis
))
/**
* [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition.
*/
public fun materialSharedAxisYIn(
initialOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInVertically(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
initialOffsetY = initialOffsetY
) + fadeIn(
animationSpec = tween(
durationMillis = durationMillis.ForIncoming,
delayMillis = durationMillis.ForOutgoing,
easing = LinearOutSlowInEasing
)
)
/**
* [materialSharedAxisYOut] allows to switch a layout with shared X-axis exit transition.
*
*/
public fun materialSharedAxisYOut(
targetOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutVertically (
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
targetOffsetY = targetOffsetY
) + fadeOut(
animationSpec = tween(
durationMillis = durationMillis.ForOutgoing,
delayMillis = 0,
easing = FastOutLinearInEasing
)
)

View File

@ -30,6 +30,7 @@ fun BottomBar(
isShow: Boolean, isShow: Boolean,
isUnread: Boolean, isUnread: Boolean,
isStarred: Boolean, isStarred: Boolean,
isNextArticleAvailable: Boolean,
isFullContent: Boolean, isFullContent: Boolean,
onUnread: (isUnread: Boolean) -> Unit = {}, onUnread: (isUnread: Boolean) -> Unit = {},
onStarred: (isStarred: Boolean) -> Unit = {}, onStarred: (isStarred: Boolean) -> Unit = {},
@ -96,7 +97,7 @@ fun BottomBar(
onStarred(!isStarred) onStarred(!isStarred)
} }
CanBeDisabledIconButton( CanBeDisabledIconButton(
disabled = false, disabled = !isNextArticleAvailable,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
imageVector = Icons.Rounded.ExpandMore, imageVector = Icons.Rounded.ExpandMore,
contentDescription = "Next Article", contentDescription = "Next Article",

View File

@ -2,22 +2,27 @@ package me.ash.reader.ui.page.home.reading
import android.util.Log import android.util.Log
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.ui.component.base.RYScaffold import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.isScrollDown import me.ash.reader.ui.ext.isScrollDown
import me.ash.reader.ui.motion.materialSharedAxisY
import me.ash.reader.ui.page.home.HomeViewModel import me.ash.reader.ui.page.home.HomeViewModel
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class)
@ -29,33 +34,35 @@ fun ReadingPage(
) { ) {
val tonalElevation = LocalReadingPageTonalElevation.current val tonalElevation = LocalReadingPageTonalElevation.current
val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
val homeUiState = homeViewModel.homeUiState.collectAsStateValue() val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
val isShowToolBar = if (LocalReadingAutoHideToolbar.current.value) { val isShowToolBar = if (LocalReadingAutoHideToolbar.current.value) {
readingUiState.articleWithFeed != null && !readingUiState.listState.isScrollDown() readingUiState.articleId != null && !readingUiState.listState.isScrollDown()
} else { } else {
true true
} }
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
readingViewModel.recorderNextArticle(pagingItems)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect { navController.currentBackStackEntryFlow.collect {
it.arguments?.getString("articleId")?.let { articleId -> it.arguments?.getString("articleId")?.let { articleId ->
if (readingUiState.articleWithFeed?.article?.id != articleId) { if (readingUiState.articleId != articleId) {
readingViewModel.initData(articleId) readingViewModel.initData(articleId)
} }
} }
} }
} }
LaunchedEffect(readingUiState.articleWithFeed?.article?.id) { LaunchedEffect(readingUiState.articleId) {
Log.i("RLog", "ReadPage: ${readingUiState.articleWithFeed}") Log.i("RLog", "ReadPage: ${readingUiState.articleWithFeed}")
readingUiState.articleWithFeed?.let { readingUiState.articleId?.let {
if (it.article.isUnread) { readingViewModel.updateNextArticleId(pagingItems)
readingViewModel.markUnread(false) if (readingUiState.isUnread) {
readingViewModel.markAsRead()
} }
} }
} }
RYScaffold( RYScaffold(
@ -69,61 +76,63 @@ fun ReadingPage(
TopBar( TopBar(
navController = navController, navController = navController,
isShow = isShowToolBar, isShow = isShowToolBar,
title = readingUiState.articleWithFeed?.article?.title, title = readerState.title,
link = readingUiState.articleWithFeed?.article?.link, link = readerState.link,
onClose = { onClose = {
navController.popBackStack() navController.popBackStack()
}, },
) )
// Content
if (readingUiState.articleWithFeed != null) {
if (readingUiState.articleId != null) {
// Content
AnimatedContent( AnimatedContent(
targetState = readingUiState.content ?: "", targetState = readerState,
transitionSpec = { transitionSpec = {
slideInVertically( if (initialState.title != targetState.title)
spring( materialSharedAxisY(
dampingRatio = Spring.DampingRatioNoBouncy, initialOffsetY = { (it * 0.1f).toInt() },
stiffness = Spring.StiffnessLow, targetOffsetY = { (it * -0.1f).toInt() })
) else {
) { height -> height / 2 } with slideOutVertically { height -> -(height / 2) } + fadeOut( ContentTransform(
spring( targetContentEnter = EnterTransition.None,
dampingRatio = Spring.DampingRatioNoBouncy, initialContentExit = ExitTransition.None, sizeTransform = null
stiffness = Spring.StiffnessLow,
) )
}
}
) {
it.run {
Content(
content = content.text ?: "",
feedName = feedName,
title = title.toString(),
author = author,
link = link,
publishedDate = publishedDate,
isLoading = content is ReaderState.Loading,
listState = readingUiState.listState,
isShowToolBar = isShowToolBar,
) )
} }
) { target ->
Content(
content = target,
feedName = readingUiState.articleWithFeed.feed.name,
title = readingUiState.articleWithFeed.article.title,
author = readingUiState.articleWithFeed.article.author,
link = readingUiState.articleWithFeed.article.link,
publishedDate = readingUiState.articleWithFeed.article.date,
isLoading = readingUiState.isLoading,
listState = readingUiState.listState,
isShowToolBar = isShowToolBar,
)
} }
} }
// Bottom Bar // Bottom Bar
if (readingUiState.articleWithFeed != null) { if (readingUiState.articleId != null) {
BottomBar( BottomBar(
isShow = isShowToolBar, isShow = isShowToolBar,
isUnread = readingUiState.articleWithFeed.article.isUnread, isUnread = readingUiState.isUnread,
isStarred = readingUiState.articleWithFeed.article.isStarred, isStarred = readingUiState.isStarred,
isFullContent = readingUiState.isFullContent, isNextArticleAvailable = readingUiState.run { !nextArticleId.isNullOrEmpty() && nextArticleId != articleId },
isFullContent = readerState.content is ReaderState.FullContent,
onUnread = { onUnread = {
readingViewModel.markUnread(it) readingViewModel.updateReadStatus(it)
}, },
onStarred = { onStarred = {
readingViewModel.markStarred(it) readingViewModel.updateStarredStatus(it)
}, },
onNextArticle = { onNextArticle = {
if (readingUiState.nextArticleId.isNotEmpty()) { readingUiState.nextArticleId?.let { readingViewModel.initData(it) }
readingViewModel.initData(readingUiState.nextArticleId)
}
}, },
onFullContent = { onFullContent = {
if (it) readingViewModel.renderFullContent() if (it) readingViewModel.renderFullContent()

View File

@ -12,10 +12,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.domain.model.article.Article
import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.article.ArticleFlowItem import me.ash.reader.domain.model.article.ArticleFlowItem
import me.ash.reader.domain.model.article.ArticleWithFeed import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.infrastructure.rss.RssHelper import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.domain.service.RssService import me.ash.reader.domain.service.RssService
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -27,14 +30,38 @@ class ReadingViewModel @Inject constructor(
private val _readingUiState = MutableStateFlow(ReadingUiState()) private val _readingUiState = MutableStateFlow(ReadingUiState())
val readingUiState: StateFlow<ReadingUiState> = _readingUiState.asStateFlow() val readingUiState: StateFlow<ReadingUiState> = _readingUiState.asStateFlow()
private val _readerState: MutableStateFlow<ReaderState> = MutableStateFlow(ReaderState())
val readerStateStateFlow = _readerState.asStateFlow()
private val currentArticle: Article?
get() = readingUiState.value.articleWithFeed?.article
private val currentFeed: Feed?
get() = readingUiState.value.articleWithFeed?.feed
fun initData(articleId: String) { fun initData(articleId: String) {
showLoading() showLoading()
viewModelScope.launch { viewModelScope.launch {
_readingUiState.update { rssService.get().findArticleById(articleId)?.run {
it.copy(articleWithFeed = rssService.get().findArticleById(articleId)) _readingUiState.update {
it.copy(
articleWithFeed = this,
articleId = article.id,
isStarred = article.isStarred,
isUnread = article.isUnread
)
}
_readerState.update {
it.copy(
feedName = feed.name,
title = article.title,
author = article.author,
link = article.link,
publishedDate = article.date,
)
}
} }
_readingUiState.value.articleWithFeed?.let { currentFeed?.let {
if (it.feed.isFullContent) internalRenderFullContent() if (it.isFullContent) internalRenderFullContent()
else renderDescriptionContent() else renderDescriptionContent()
} }
// java.lang.NullPointerException: Attempt to invoke virtual method // java.lang.NullPointerException: Attempt to invoke virtual method
@ -43,16 +70,16 @@ class ReadingViewModel @Inject constructor(
if (_readingUiState.value.listState.firstVisibleItemIndex != 0) { if (_readingUiState.value.listState.firstVisibleItemIndex != 0) {
_readingUiState.value.listState.scrollToItem(0) _readingUiState.value.listState.scrollToItem(0)
} }
hideLoading()
} }
} }
fun renderDescriptionContent() { fun renderDescriptionContent() {
_readingUiState.update { _readerState.update {
it.copy( it.copy(
content = it.articleWithFeed?.article?.fullContent content = ReaderState.Description(
?: it.articleWithFeed?.article?.rawDescription ?: "", content = currentArticle?.fullContent
isFullContent = false ?: currentArticle?.rawDescription ?: ""
)
) )
} }
} }
@ -65,107 +92,103 @@ class ReadingViewModel @Inject constructor(
private suspend fun internalRenderFullContent() { private suspend fun internalRenderFullContent() {
showLoading() showLoading()
try { runCatching {
_readingUiState.update { rssHelper.parseFullContent(
it.copy( currentArticle?.link ?: "",
content = rssHelper.parseFullContent( currentArticle?.title ?: ""
_readingUiState.value.articleWithFeed?.article?.link ?: "",
_readingUiState.value.articleWithFeed?.article?.title ?: ""
),
isFullContent = true
)
}
} catch (e: Exception) {
Log.i("RLog", "renderFullContent: ${e.message}")
_readingUiState.update { it.copy(content = e.message) }
}
hideLoading()
}
fun markUnread(isUnread: Boolean) {
val articleWithFeed = _readingUiState.value.articleWithFeed ?: return
viewModelScope.launch {
_readingUiState.update {
it.copy(
articleWithFeed = articleWithFeed.copy(
article = articleWithFeed.article.copy(
isUnread = isUnread
)
)
)
}
rssService.get().markAsRead(
groupId = null,
feedId = null,
articleId = _readingUiState.value.articleWithFeed!!.article.id,
before = null,
isUnread = isUnread,
) )
}.onSuccess { content ->
_readerState.update { it.copy(content = ReaderState.FullContent(content = content)) }
}.onFailure { th ->
Log.i("RLog", "renderFullContent: ${th.message}")
_readerState.update { it.copy(content = ReaderState.Error(th.message)) }
} }
} }
fun markStarred(isStarred: Boolean) { fun updateReadStatus(isUnread: Boolean) {
val articleWithFeed = _readingUiState.value.articleWithFeed ?: return currentArticle?.run {
viewModelScope.launch {
_readingUiState.update { it.copy(isUnread = isUnread) }
rssService.get().markAsRead(
groupId = null,
feedId = null,
articleId = id,
before = null,
isUnread = isUnread,
)
}
}
}
fun markAsRead() = updateReadStatus(isUnread = false)
fun markAsUnread() = updateReadStatus(isUnread = true)
fun updateStarredStatus(isStarred: Boolean) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_readingUiState.update { _readingUiState.update { it.copy(isStarred = isStarred) }
it.copy( currentArticle?.let {
articleWithFeed = articleWithFeed.copy( rssService.get().markAsStarred(
article = articleWithFeed.article.copy( articleId = it.id,
isStarred = isStarred isStarred = isStarred,
)
)
) )
} }
rssService.get().markAsStarred(
articleId = articleWithFeed.article.id,
isStarred = isStarred,
)
} }
} }
private fun showLoading() { private fun showLoading() {
_readingUiState.update { _readerState.update {
it.copy(isLoading = true) it.copy(content = ReaderState.Loading)
} }
} }
private fun hideLoading() { fun updateNextArticleId(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
_readingUiState.update { val items = pagingItems.items
it.copy(isLoading = false) val index = items.indexOfFirst { item ->
item is ArticleFlowItem.Article && item.articleWithFeed.article.id == currentArticle?.id
} }
} items.subList(index + 1, items.size).forEach { item ->
if (item is ArticleFlowItem.Article) {
fun recorderNextArticle(pagingItems: ItemSnapshotList<ArticleFlowItem>) { _readingUiState.update { it.copy(nextArticleId = item.articleWithFeed.article.id) }
if (pagingItems.size > 0) { return
val cur = _readingUiState.value.articleWithFeed?.article
if (cur != null) {
var found = false
for (item in pagingItems) {
if (item is ArticleFlowItem.Article) {
val itemId = item.articleWithFeed.article.id
if (itemId == cur.id) {
found = true
_readingUiState.update {
it.copy(nextArticleId = "")
}
} else if (found) {
_readingUiState.update {
it.copy(nextArticleId = itemId)
}
break
}
}
}
} }
} }
_readingUiState.update { it.copy(nextArticleId = null) }
} }
} }
data class ReadingUiState( data class ReadingUiState(
val articleWithFeed: ArticleWithFeed? = null, val articleWithFeed: ArticleWithFeed? = null,
val content: String? = null, val articleId: String? = null,
val isFullContent: Boolean = false, val isUnread: Boolean = false,
val isLoading: Boolean = true, val isStarred: Boolean = false,
val listState: LazyListState = LazyListState(), val listState: LazyListState = LazyListState(),
val nextArticleId: String = "", val nextArticleId: String? = null,
) )
data class ReaderState(
val feedName: String = "",
val title: String? = null,
val author: String? = null,
val link: String? = null,
val publishedDate: Date = Date(0L),
val content: ContentState = Description(null)
) {
sealed interface ContentState {
val text: String?
get() {
return when (this) {
is Description -> content
is Error -> message
is FullContent -> content
Loading -> null
}
}
}
data class FullContent(val content: String?) : ContentState
data class Description(val content: String?) : ContentState
data class Error(val message: String?) : ContentState
object Loading: ContentState
}