mirror of https://github.com/Ashinch/ReadYou.git
refactor(ui): refactor `ReadingPage` & `ReadingViewModel` (#559)
* refactor(ui): refactor `ReadingPage` & `ReadingViewModel` * fix(ui): disable action when next article unavailable
This commit is contained in:
parent
de5e6cf0f8
commit
610895fcdc
|
@ -27,7 +27,9 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
@ -114,3 +116,62 @@ public fun materialSharedAxisXOut(
|
|||
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
|
||||
)
|
||||
)
|
|
@ -30,6 +30,7 @@ fun BottomBar(
|
|||
isShow: Boolean,
|
||||
isUnread: Boolean,
|
||||
isStarred: Boolean,
|
||||
isNextArticleAvailable: Boolean,
|
||||
isFullContent: Boolean,
|
||||
onUnread: (isUnread: Boolean) -> Unit = {},
|
||||
onStarred: (isStarred: Boolean) -> Unit = {},
|
||||
|
@ -96,7 +97,7 @@ fun BottomBar(
|
|||
onStarred(!isStarred)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = false,
|
||||
disabled = !isNextArticleAvailable,
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = Icons.Rounded.ExpandMore,
|
||||
contentDescription = "Next Article",
|
||||
|
|
|
@ -2,22 +2,27 @@ package me.ash.reader.ui.page.home.reading
|
|||
|
||||
import android.util.Log
|
||||
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.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
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.LocalReadingPageTonalElevation
|
||||
import me.ash.reader.ui.component.base.RYScaffold
|
||||
import me.ash.reader.ui.ext.collectAsStateValue
|
||||
import me.ash.reader.ui.ext.isScrollDown
|
||||
import me.ash.reader.ui.motion.materialSharedAxisY
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
|
@ -29,33 +34,35 @@ fun ReadingPage(
|
|||
) {
|
||||
val tonalElevation = LocalReadingPageTonalElevation.current
|
||||
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
|
||||
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
|
||||
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
|
||||
val isShowToolBar = if (LocalReadingAutoHideToolbar.current.value) {
|
||||
readingUiState.articleWithFeed != null && !readingUiState.listState.isScrollDown()
|
||||
readingUiState.articleId != null && !readingUiState.listState.isScrollDown()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
|
||||
readingViewModel.recorderNextArticle(pagingItems)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navController.currentBackStackEntryFlow.collect {
|
||||
it.arguments?.getString("articleId")?.let { articleId ->
|
||||
if (readingUiState.articleWithFeed?.article?.id != articleId) {
|
||||
if (readingUiState.articleId != articleId) {
|
||||
readingViewModel.initData(articleId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(readingUiState.articleWithFeed?.article?.id) {
|
||||
LaunchedEffect(readingUiState.articleId) {
|
||||
Log.i("RLog", "ReadPage: ${readingUiState.articleWithFeed}")
|
||||
readingUiState.articleWithFeed?.let {
|
||||
if (it.article.isUnread) {
|
||||
readingViewModel.markUnread(false)
|
||||
readingUiState.articleId?.let {
|
||||
readingViewModel.updateNextArticleId(pagingItems)
|
||||
if (readingUiState.isUnread) {
|
||||
readingViewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RYScaffold(
|
||||
|
@ -69,61 +76,63 @@ fun ReadingPage(
|
|||
TopBar(
|
||||
navController = navController,
|
||||
isShow = isShowToolBar,
|
||||
title = readingUiState.articleWithFeed?.article?.title,
|
||||
link = readingUiState.articleWithFeed?.article?.link,
|
||||
title = readerState.title,
|
||||
link = readerState.link,
|
||||
onClose = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
|
||||
// Content
|
||||
if (readingUiState.articleWithFeed != null) {
|
||||
|
||||
|
||||
if (readingUiState.articleId != null) {
|
||||
// Content
|
||||
AnimatedContent(
|
||||
targetState = readingUiState.content ?: "",
|
||||
targetState = readerState,
|
||||
transitionSpec = {
|
||||
slideInVertically(
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
)
|
||||
) { height -> height / 2 } with slideOutVertically { height -> -(height / 2) } + fadeOut(
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
if (initialState.title != targetState.title)
|
||||
materialSharedAxisY(
|
||||
initialOffsetY = { (it * 0.1f).toInt() },
|
||||
targetOffsetY = { (it * -0.1f).toInt() })
|
||||
else {
|
||||
ContentTransform(
|
||||
targetContentEnter = EnterTransition.None,
|
||||
initialContentExit = ExitTransition.None, sizeTransform = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
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
|
||||
if (readingUiState.articleWithFeed != null) {
|
||||
if (readingUiState.articleId != null) {
|
||||
BottomBar(
|
||||
isShow = isShowToolBar,
|
||||
isUnread = readingUiState.articleWithFeed.article.isUnread,
|
||||
isStarred = readingUiState.articleWithFeed.article.isStarred,
|
||||
isFullContent = readingUiState.isFullContent,
|
||||
isUnread = readingUiState.isUnread,
|
||||
isStarred = readingUiState.isStarred,
|
||||
isNextArticleAvailable = readingUiState.run { !nextArticleId.isNullOrEmpty() && nextArticleId != articleId },
|
||||
isFullContent = readerState.content is ReaderState.FullContent,
|
||||
onUnread = {
|
||||
readingViewModel.markUnread(it)
|
||||
readingViewModel.updateReadStatus(it)
|
||||
},
|
||||
onStarred = {
|
||||
readingViewModel.markStarred(it)
|
||||
readingViewModel.updateStarredStatus(it)
|
||||
},
|
||||
onNextArticle = {
|
||||
if (readingUiState.nextArticleId.isNotEmpty()) {
|
||||
readingViewModel.initData(readingUiState.nextArticleId)
|
||||
}
|
||||
readingUiState.nextArticleId?.let { readingViewModel.initData(it) }
|
||||
},
|
||||
onFullContent = {
|
||||
if (it) readingViewModel.renderFullContent()
|
||||
|
|
|
@ -12,10 +12,13 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
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.ArticleWithFeed
|
||||
import me.ash.reader.infrastructure.rss.RssHelper
|
||||
import me.ash.reader.domain.service.RssService
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -27,14 +30,38 @@ class ReadingViewModel @Inject constructor(
|
|||
private val _readingUiState = MutableStateFlow(ReadingUiState())
|
||||
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) {
|
||||
showLoading()
|
||||
viewModelScope.launch {
|
||||
_readingUiState.update {
|
||||
it.copy(articleWithFeed = rssService.get().findArticleById(articleId))
|
||||
rssService.get().findArticleById(articleId)?.run {
|
||||
_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 {
|
||||
if (it.feed.isFullContent) internalRenderFullContent()
|
||||
currentFeed?.let {
|
||||
if (it.isFullContent) internalRenderFullContent()
|
||||
else renderDescriptionContent()
|
||||
}
|
||||
// java.lang.NullPointerException: Attempt to invoke virtual method
|
||||
|
@ -43,16 +70,16 @@ class ReadingViewModel @Inject constructor(
|
|||
if (_readingUiState.value.listState.firstVisibleItemIndex != 0) {
|
||||
_readingUiState.value.listState.scrollToItem(0)
|
||||
}
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
fun renderDescriptionContent() {
|
||||
_readingUiState.update {
|
||||
_readerState.update {
|
||||
it.copy(
|
||||
content = it.articleWithFeed?.article?.fullContent
|
||||
?: it.articleWithFeed?.article?.rawDescription ?: "",
|
||||
isFullContent = false
|
||||
content = ReaderState.Description(
|
||||
content = currentArticle?.fullContent
|
||||
?: currentArticle?.rawDescription ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -65,107 +92,103 @@ class ReadingViewModel @Inject constructor(
|
|||
|
||||
private suspend fun internalRenderFullContent() {
|
||||
showLoading()
|
||||
try {
|
||||
_readingUiState.update {
|
||||
it.copy(
|
||||
content = rssHelper.parseFullContent(
|
||||
_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,
|
||||
runCatching {
|
||||
rssHelper.parseFullContent(
|
||||
currentArticle?.link ?: "",
|
||||
currentArticle?.title ?: ""
|
||||
)
|
||||
}.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) {
|
||||
val articleWithFeed = _readingUiState.value.articleWithFeed ?: return
|
||||
fun updateReadStatus(isUnread: Boolean) {
|
||||
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) {
|
||||
_readingUiState.update {
|
||||
it.copy(
|
||||
articleWithFeed = articleWithFeed.copy(
|
||||
article = articleWithFeed.article.copy(
|
||||
isStarred = isStarred
|
||||
)
|
||||
)
|
||||
_readingUiState.update { it.copy(isStarred = isStarred) }
|
||||
currentArticle?.let {
|
||||
rssService.get().markAsStarred(
|
||||
articleId = it.id,
|
||||
isStarred = isStarred,
|
||||
)
|
||||
}
|
||||
rssService.get().markAsStarred(
|
||||
articleId = articleWithFeed.article.id,
|
||||
isStarred = isStarred,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoading() {
|
||||
_readingUiState.update {
|
||||
it.copy(isLoading = true)
|
||||
_readerState.update {
|
||||
it.copy(content = ReaderState.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
_readingUiState.update {
|
||||
it.copy(isLoading = false)
|
||||
fun updateNextArticleId(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
|
||||
val items = pagingItems.items
|
||||
val index = items.indexOfFirst { item ->
|
||||
item is ArticleFlowItem.Article && item.articleWithFeed.article.id == currentArticle?.id
|
||||
}
|
||||
}
|
||||
|
||||
fun recorderNextArticle(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
|
||||
if (pagingItems.size > 0) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
items.subList(index + 1, items.size).forEach { item ->
|
||||
if (item is ArticleFlowItem.Article) {
|
||||
_readingUiState.update { it.copy(nextArticleId = item.articleWithFeed.article.id) }
|
||||
return
|
||||
}
|
||||
}
|
||||
_readingUiState.update { it.copy(nextArticleId = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadingUiState(
|
||||
val articleWithFeed: ArticleWithFeed? = null,
|
||||
val content: String? = null,
|
||||
val isFullContent: Boolean = false,
|
||||
val isLoading: Boolean = true,
|
||||
val articleId: String? = null,
|
||||
val isUnread: Boolean = false,
|
||||
val isStarred: Boolean = false,
|
||||
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
|
||||
}
|
Loading…
Reference in New Issue