Optimize the reading page

This commit is contained in:
Ash 2022-05-23 20:54:06 +08:00
parent ae394da387
commit ba3620d84f
22 changed files with 525 additions and 486 deletions

View File

@ -166,9 +166,9 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/compose-material
implementation "androidx.compose.material:material:$compose"
implementation "androidx.compose.material:material-icons-extended:$compose"
debugImplementation "androidx.compose.ui:ui-tooling:$compose"
implementation "androidx.compose.ui:ui-tooling-preview:$compose"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose"
debugImplementation "androidx.compose.ui:ui-tooling:$compose"
// hilt
implementation "androidx.hilt:hilt-work:1.0.0"

View File

@ -1,6 +1,6 @@
package me.ash.reader.ui.component.base
import androidx.compose.animation.*
import RYExtensibleVisibility
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
@ -38,11 +38,7 @@ fun AnimatedPopup(
}
},
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = visible) {
content()
}
}

View File

@ -1,6 +1,6 @@
package me.ash.reader.ui.component.base
import androidx.compose.animation.*
import RYExtensibleVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -41,11 +41,7 @@ fun DisplayText(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AnimatedVisibility(
visible = desc.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = desc.isNotEmpty()) {
Text(
modifier = Modifier.height(16.dp),
text = desc,

View File

@ -1,7 +1,7 @@
package me.ash.reader.ui.component.base
import androidx.annotation.DrawableRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
@ -10,10 +10,7 @@ import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.LocalImageLoader
import coil.request.ImageRequest
import coil.compose.rememberImagePainter
import coil.size.Precision
import coil.size.Scale
import coil.size.Size
@ -33,34 +30,51 @@ fun RYAsyncImage(
@DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp,
@DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp,
) {
coil.compose.AsyncImage(
modifier = modifier,
model = ImageRequest
.Builder(LocalContext.current)
.data(data)
.crossfade(true)
.scale(scale)
.precision(precision)
.size(size)
.build(),
Image(
painter = rememberImagePainter(
data = data,
builder = {
if (placeholder != null) placeholder(placeholder)
if (error != null) error(error)
crossfade(true)
scale(scale)
precision(precision)
size(size)
},
),
contentDescription = contentDescription,
contentScale = contentScale,
imageLoader = LocalImageLoader.current,
placeholder = placeholder?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
error = error?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
modifier = modifier,
)
// coil.compose.AsyncImage(
// modifier = modifier,
// model = ImageRequest
// .Builder(LocalContext.current)
// .data(data)
// .crossfade(true)
// .scale(scale)
// .precision(precision)
// .size(size)
// .build(),
// contentDescription = contentDescription,
// contentScale = contentScale,
// imageLoader = LocalImageLoader.current,
// placeholder = placeholder?.run {
// forwardingPainter(
// painter = painterResource(this),
// colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
// alpha = 0.1f,
// )
// },
// error = error?.run {
// forwardingPainter(
// painter = painterResource(this),
// colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
// alpha = 0.1f,
// )
// },
// )
}
// From: https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1

View File

@ -0,0 +1,15 @@
import androidx.compose.animation.*
import androidx.compose.runtime.Composable
@Composable
fun RYExtensibleVisibility(
visible: Boolean,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
content = content,
)
}

View File

@ -26,7 +26,7 @@ import me.ash.reader.ui.theme.palette.alwaysLight
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SelectionChip(
fun RYSelectionChip(
content: String,
selected: Boolean,
modifier: Modifier = Modifier,

View File

@ -27,7 +27,8 @@ import android.util.Log
import androidx.compose.foundation.lazy.LazyListScope
import me.ash.reader.R
fun LazyListScope.reader(
@Suppress("FunctionName")
fun LazyListScope.Reader(
context: Context,
link: String,
content: String,

View File

@ -1,16 +1,19 @@
package me.ash.reader.ui.ext
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.ln
@Composable
fun ColorScheme.surfaceColorAtElevation(
elevation: Dp,
color: Color = surface,
): Color = color.atElevation(surfaceTint, elevation)
): Color = remember(this, elevation, color) { color.atElevation(surfaceTint, elevation) }
fun Color.atElevation(
sourceColor: Color,

View File

@ -4,9 +4,11 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import me.ash.reader.R
import me.ash.reader.data.model.Version
import me.ash.reader.data.model.toVersion
import java.io.File
@ -53,4 +55,19 @@ fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) {
fun Context.showToastLong(message: String?) {
showToast(message, Toast.LENGTH_LONG)
}
fun Context.share(content: String) {
startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
putExtra(
Intent.EXTRA_TEXT,
content,
)
type = "text/plain"
}, getString(R.string.share)))
}
fun Context.openURL(url: String? = null) {
url?.takeIf { it.trim().isNotEmpty() }
?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }
}

View File

@ -1,8 +1,7 @@
package me.ash.reader.ui.ext
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.paging.compose.LazyPagingItems
import kotlin.math.abs
@ -27,4 +26,34 @@ fun <T : Any> LazyPagingItems<T>.rememberLazyListState(): LazyListState {
// Return rememberLazyListState (normal case).
else -> androidx.compose.foundation.lazy.rememberLazyListState()
}
}
/**
* TODO: To be improved
*
* Returns whether the LazyListState is currently in the
* downward scrolling state.
*/
@Composable
fun LazyListState.isScrollDown(): Boolean {
var isScrollDown by remember { mutableStateOf(false) }
var preItemIndex by remember { mutableStateOf(0) }
var preScrollStartOffset by remember { mutableStateOf(0) }
LaunchedEffect(this) {
snapshotFlow { isScrollInProgress }.collect {
if (isScrollInProgress) {
isScrollDown = when {
firstVisibleItemIndex > preItemIndex -> true
firstVisibleItemScrollOffset < preItemIndex -> false
else -> firstVisibleItemScrollOffset > preScrollStartOffset
}
} else {
preItemIndex = firstVisibleItemIndex
preScrollStartOffset = firstVisibleItemScrollOffset
}
}
}
return isScrollDown
}

View File

@ -52,6 +52,7 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
@ -173,11 +174,15 @@ private fun CacheDrawScope.onDrawScrollbar(
return {
if (showScrollbar) {
drawRect(
drawRoundRect(
color = color,
topLeft = topLeft,
size = size,
alpha = alpha()
alpha = alpha(),
cornerRadius = CornerRadius(
x = size.width,
y = size.width,
)
)
}
}
@ -217,7 +222,7 @@ private fun Modifier.drawScrollbar(
val alpha = remember { Animatable(0f) }
LaunchedEffect(scrolled, alpha) {
scrolled.collectLatest {
alpha.snapTo(1f)
alpha.snapTo(0.3f)
delay(ViewConfiguration.getScrollDefaultDelay().toLong())
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
}
@ -231,7 +236,7 @@ private fun Modifier.drawScrollbar(
// Calculate thickness here to workaround https://issuetracker.google.com/issues/206972664
val thickness = with(LocalDensity.current) { Thickness.toPx() }
val color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
val color = MaterialTheme.colorScheme.onSurfaceVariant
Modifier
.nestedScroll(nestedScrollConnection)
.drawWithCache {

View File

@ -1,7 +1,7 @@
package me.ash.reader.ui.page.home.feeds
import RYExtensibleVisibility
import android.view.HapticFeedbackConstants
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@ -104,11 +104,7 @@ fun GroupItem(
}
}
Spacer(modifier = Modifier.height(22.dp))
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = expanded) {
Column {
feeds.forEach { feed ->
FeedItem(

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.data.entity.Group
import me.ash.reader.ui.component.base.BottomDrawer
import me.ash.reader.ui.component.base.SelectionChip
import me.ash.reader.ui.component.base.RYSelectionChip
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.component.base.TextFieldDialog
import me.ash.reader.ui.ext.*
@ -162,7 +162,7 @@ private fun Preset(
crossAxisSpacing = 10.dp,
mainAxisSpacing = 10.dp,
) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.allow_notification),
selected = false,
@ -178,7 +178,7 @@ private fun Preset(
) {
groupOptionViewModel.showAllAllowNotificationDialog()
}
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.parse_full_content),
selected = false,
@ -194,7 +194,7 @@ private fun Preset(
) {
groupOptionViewModel.showAllParseFullContentDialog()
}
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.clear_articles),
selected = false,
@ -202,7 +202,7 @@ private fun Preset(
groupOptionViewModel.showClearDialog()
}
if (group?.id != context.currentAccountId.getDefaultGroupId()) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.delete_group),
selected = false,
@ -227,7 +227,7 @@ private fun FlowRowGroups(
) {
groupOptionUiState.groups.forEach {
if (it.id != group?.id) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = it.name,
selected = false,
@ -248,7 +248,7 @@ private fun LazyRowGroups(
LazyRow {
items(groupOptionUiState.groups) {
if (it.id != group?.id) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = it.name,
selected = false,

View File

@ -30,7 +30,7 @@ import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.flowlayout.MainAxisAlignment
import me.ash.reader.R
import me.ash.reader.data.entity.Group
import me.ash.reader.ui.component.base.SelectionChip
import me.ash.reader.ui.component.base.RYSelectionChip
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.ext.roundClick
import me.ash.reader.ui.theme.palette.alwaysLight
@ -127,7 +127,7 @@ private fun Preset(
crossAxisSpacing = 10.dp,
mainAxisSpacing = 10.dp,
) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.allow_notification),
selected = selectedAllowNotificationPreset,
@ -144,7 +144,7 @@ private fun Preset(
) {
allowNotificationPresetOnClick()
}
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.parse_full_content),
selected = selectedParseFullContentPreset,
@ -162,14 +162,14 @@ private fun Preset(
parseFullContentPresetOnClick()
}
if (showUnsubscribe) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.clear_articles),
selected = false,
) {
clearArticlesOnClick()
}
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.unsubscribe),
selected = false,
@ -196,7 +196,7 @@ private fun AddToGroup(
verticalAlignment = Alignment.CenterVertically,
) {
items(groups) {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = it.name,
selected = it.id == selectedGroupId,
@ -215,7 +215,7 @@ private fun AddToGroup(
mainAxisSpacing = 10.dp,
) {
groups.forEach {
SelectionChip(
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = it.name,
selected = it.id == selectedGroupId,

View File

@ -1,7 +1,7 @@
package me.ash.reader.ui.page.home.flow
import RYExtensibleVisibility
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
@ -19,7 +19,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -121,11 +120,7 @@ fun FlowPage(
}
},
actions = {
AnimatedVisibility(
visible = !filterUiState.filter.isStarred(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = !filterUiState.filter.isStarred()) {
FeedbackIconButton(
imageVector = Icons.Rounded.DoneAll,
contentDescription = stringResource(R.string.mark_all_as_read),
@ -171,11 +166,7 @@ fun FlowPage(
) {
item {
DisplayTextHeader(filterUiState, isSyncing, articleListFeedIcon.value)
AnimatedVisibility(
visible = markAsRead,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = markAsRead) {
Spacer(modifier = Modifier.height((56 + 24 + 10).dp))
}
MarkAsReadBar(
@ -193,11 +184,7 @@ fun FlowPage(
markAsReadBefore = it,
)
}
AnimatedVisibility(
visible = onSearch,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = onSearch) {
SearchBar(
value = homeUiState.searchContent,
placeholder = when {

View File

@ -0,0 +1,131 @@
package me.ash.reader.ui.page.home.reading
import RYExtensibleVisibility
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.FiberManualRecord
import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.rounded.Article
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.R
import me.ash.reader.ui.component.base.CanBeDisabledIconButton
@Composable
fun BottomBar(
isShow: Boolean,
isUnread: Boolean,
isStarred: Boolean,
isFullContent: Boolean,
onUnread: (isUnread: Boolean) -> Unit = {},
onStarred: (isStarred: Boolean) -> Unit = {},
onFullContent: (isFullContent: Boolean) -> Unit = {},
) {
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
contentAlignment = Alignment.BottomCenter
) {
RYExtensibleVisibility(visible = isShow) {
val view = LocalView.current
Surface(modifier = Modifier.navigationBarsPadding()) {
// TODO: Component styles await refactoring
Row(
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
CanBeDisabledIconButton(
modifier = Modifier.size(40.dp),
disabled = false,
imageVector = if (isUnread) {
Icons.Filled.FiberManualRecord
} else {
Icons.Outlined.FiberManualRecord
},
contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread),
tint = if (isUnread) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onUnread(!isUnread)
}
CanBeDisabledIconButton(
modifier = Modifier.size(40.dp),
disabled = false,
imageVector = if (isStarred) {
Icons.Rounded.Star
} else {
Icons.Rounded.StarOutline
},
contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred),
tint = if (isStarred) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onStarred(!isStarred)
}
CanBeDisabledIconButton(
disabled = true,
modifier = Modifier.size(40.dp),
imageVector = Icons.Rounded.ExpandMore,
contentDescription = "Next Article",
tint = MaterialTheme.colorScheme.outline,
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
modifier = Modifier.size(36.dp),
disabled = true,
imageVector = Icons.Outlined.Headphones,
contentDescription = "Add Tag",
tint = MaterialTheme.colorScheme.outline,
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
disabled = false,
modifier = Modifier.size(40.dp),
imageVector = if (isFullContent) {
Icons.Rounded.Article
} else {
Icons.Outlined.Article
},
contentDescription = stringResource(R.string.parse_full_content),
tint = if (isFullContent) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onFullContent(!isFullContent)
}
}
}
}
}
}

View File

@ -0,0 +1,101 @@
package me.ash.reader.ui.page.home.reading
import RYExtensibleVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.ash.reader.ui.component.reader.Reader
import me.ash.reader.ui.ext.drawVerticalScrollbar
import java.util.*
@Composable
fun Content(
content: String,
feedName: String,
title: String,
author: String? = null,
link: String? = null,
publishedDate: Date,
listState: LazyListState,
isLoading: Boolean,
isShowToolBar: Boolean,
) {
val context = LocalContext.current
SelectionContainer {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.run {
if (isShowToolBar) {
navigationBarsPadding()
} else {
this
}
}
.drawVerticalScrollbar(listState),
state = listState,
) {
item {
// Top bar height
Spacer(modifier = Modifier.height(64.dp))
// padding
Spacer(modifier = Modifier.height(22.dp))
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
DisableSelection {
Header(
feedName = feedName,
title = title,
author = author,
link = link,
publishedDate = publishedDate,
)
}
}
}
item {
Spacer(modifier = Modifier.height(22.dp))
RYExtensibleVisibility(visible = isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column {
Spacer(modifier = Modifier.height(22.dp))
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(22.dp))
}
}
}
}
if (!isLoading) {
Reader(
context = context,
link = link ?: "",
content = content
)
}
item {
Spacer(modifier = Modifier.height(128.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
}

View File

@ -1,62 +1,67 @@
package me.ash.reader.ui.page.home.reading
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.ui.ext.formatAsString
import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.ext.roundClick
import java.util.*
@Composable
fun Header(
articleWithFeed: ArticleWithFeed,
feedName: String,
title: String,
author: String? = null,
link: String? = null,
publishedDate: Date,
) {
val context = LocalContext.current
val dateString = remember(publishedDate) {
publishedDate.formatAsString(context, atHourMinute = true)
}
Column(
modifier = Modifier
.fillMaxWidth()
.roundClick {
articleWithFeed.article.link.let {
if (it.isNotEmpty()) {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(articleWithFeed.article.link))
)
}
}
context.openURL(link)
}
.padding(12.dp)
) {
Text(
text = articleWithFeed.article.date.formatAsString(context, atHourMinute = true),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
modifier = Modifier.alpha(0.7f),
text = dateString,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.labelMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = articleWithFeed.article.title,
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(4.dp))
articleWithFeed.article.author?.let {
author?.let {
if (it.isNotEmpty()) {
Text(
modifier = Modifier.alpha(0.7f),
text = it,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.labelMedium,
)
}
}
Text(
text = articleWithFeed.feed.name,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
modifier = Modifier.alpha(0.7f),
text = feedName,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.labelMedium,
)
}

View File

@ -1,140 +0,0 @@
package me.ash.reader.ui.page.home.reading
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.FiberManualRecord
import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.rounded.Article
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.R
import me.ash.reader.ui.component.base.CanBeDisabledIconButton
@Composable
fun ReadingBar(
modifier: Modifier = Modifier,
disabled: Boolean,
isUnread: Boolean,
isStarred: Boolean,
isFullContent: Boolean,
unreadOnClick: (afterIsUnread: Boolean) -> Unit = {},
starredOnClick: (afterIsStarred: Boolean) -> Unit = {},
fullContentOnClick: (afterIsFullContent: Boolean) -> Unit = {},
) {
val view = LocalView.current
var fullContent by remember { mutableStateOf(isFullContent) }
Surface(
modifier = modifier.background(MaterialTheme.colorScheme.surface),
tonalElevation = 0.dp,
) {
Box(
modifier = Modifier.height(60.dp)
) {
Box {
Divider(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.zIndex(1f),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.24f)
)
}
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
CanBeDisabledIconButton(
modifier = Modifier.size(40.dp),
disabled = disabled,
imageVector = if (isUnread) {
Icons.Filled.FiberManualRecord
} else {
Icons.Outlined.FiberManualRecord
},
contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread),
tint = if (isUnread) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
unreadOnClick(!isUnread)
}
CanBeDisabledIconButton(
modifier = Modifier.size(40.dp),
disabled = disabled,
imageVector = if (isStarred) {
Icons.Rounded.Star
} else {
Icons.Rounded.StarOutline
},
contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred),
tint = if (isStarred) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
starredOnClick(!isStarred)
}
CanBeDisabledIconButton(
disabled = true,
modifier = Modifier.size(40.dp),
imageVector = Icons.Rounded.ExpandMore,
contentDescription = "Next Article",
tint = MaterialTheme.colorScheme.outline,
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
modifier = Modifier.size(36.dp),
disabled = true,
imageVector = Icons.Outlined.Headphones,
contentDescription = "Add Tag",
tint = MaterialTheme.colorScheme.outline,
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
disabled = disabled,
modifier = Modifier.size(40.dp),
imageVector = if (fullContent) {
Icons.Rounded.Article
} else {
Icons.Outlined.Article
},
contentDescription = stringResource(R.string.parse_full_content),
tint = if (fullContent) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
val afterIsFullContent = !fullContent
fullContent = afterIsFullContent
fullContentOnClick(afterIsFullContent)
}
}
}
}
}

View File

@ -1,36 +1,16 @@
package me.ash.reader.ui.page.home.reading
import android.content.Intent
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.reader.reader
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.drawVerticalScrollbar
import me.ash.reader.ui.ext.isScrollDown
@Composable
fun ReadingPage(
@ -38,7 +18,8 @@ fun ReadingPage(
readingViewModel: ReadingViewModel = hiltViewModel(),
) {
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val isScrollDown = readingUiState.listState.isScrollDown()
val isShowToolBar =
readingUiState.articleWithFeed != null && !readingUiState.listState.isScrollDown()
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect {
@ -59,46 +40,48 @@ fun ReadingPage(
RYScaffold(
content = {
Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
contentAlignment = Alignment.TopCenter
) {
TopBar(
isShow = readingUiState.articleWithFeed == null || !isScrollDown,
title = readingUiState.articleWithFeed?.article?.title,
link = readingUiState.articleWithFeed?.article?.link,
onClose = {
navController.popBackStack()
},
Log.i("RLog", "TopBar: recomposition")
Box(modifier = Modifier.fillMaxSize()) {
// Top Bar
TopBar(
isShow = isShowToolBar,
title = readingUiState.articleWithFeed?.article?.title,
link = readingUiState.articleWithFeed?.article?.link,
onClose = {
navController.popBackStack()
},
)
// Content
if (readingUiState.articleWithFeed != null) {
Content(
content = readingUiState.content ?: "",
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,
)
}
Content(
content = readingUiState.content ?: "",
articleWithFeed = readingUiState.articleWithFeed,
isLoading = readingUiState.isLoading,
listState = readingUiState.listState,
isShowToolBar = readingUiState.articleWithFeed == null || !isScrollDown,
)
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
contentAlignment = Alignment.BottomCenter
) {
// Bottom Bar
if (readingUiState.articleWithFeed != null) {
BottomBar(
isShow = readingUiState.articleWithFeed != null && !isScrollDown,
articleWithFeed = readingUiState.articleWithFeed,
unreadOnClick = {
isShow = isShowToolBar,
isUnread = readingUiState.articleWithFeed.article.isUnread,
isStarred = readingUiState.articleWithFeed.article.isStarred,
isFullContent = readingUiState.isFullContent,
onUnread = {
readingViewModel.markUnread(it)
},
starredOnClick = {
onStarred = {
readingViewModel.markStarred(it)
},
fullContentOnClick = { afterIsFullContent ->
if (afterIsFullContent) readingViewModel.renderFullContent()
onFullContent = {
if (it) readingViewModel.renderFullContent()
else readingViewModel.renderDescriptionContent()
},
)
@ -107,180 +90,3 @@ fun ReadingPage(
}
)
}
@Composable
fun LazyListState.isScrollDown(): Boolean {
var isScrollDown by remember { mutableStateOf(false) }
var preItemIndex by remember { mutableStateOf(0) }
var preScrollStartOffset by remember { mutableStateOf(0) }
LaunchedEffect(this) {
snapshotFlow { isScrollInProgress }.collect {
if (isScrollInProgress) {
isScrollDown = when {
firstVisibleItemIndex > preItemIndex -> true
firstVisibleItemScrollOffset < preItemIndex -> false
else -> firstVisibleItemScrollOffset > preScrollStartOffset
}
} else {
preItemIndex = firstVisibleItemIndex
preScrollStartOffset = firstVisibleItemScrollOffset
}
}
}
return isScrollDown
}
@Composable
private fun TopBar(
isShow: Boolean,
title: String? = "",
link: String? = "",
onClose: () -> Unit = {},
) {
val context = LocalContext.current
AnimatedVisibility(
visible = isShow,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
SmallTopAppBar(
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
title = {},
navigationIcon = {
FeedbackIconButton(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.close),
tint = MaterialTheme.colorScheme.onSurface
) {
onClose()
}
},
actions = {
FeedbackIconButton(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.share),
tint = MaterialTheme.colorScheme.onSurface,
) {
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
putExtra(
Intent.EXTRA_TEXT,
title?.takeIf { it.isNotBlank() }?.let { it + "\n" } + link
)
type = "text/plain"
}, context.getString(R.string.share)))
}
}
)
}
}
@Composable
private fun Content(
content: String,
articleWithFeed: ArticleWithFeed?,
listState: LazyListState,
isLoading: Boolean,
isShowToolBar: Boolean,
) {
if (articleWithFeed == null) return
val context = LocalContext.current
SelectionContainer {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.run {
if (isShowToolBar) {
navigationBarsPadding()
} else {
this
}
}
.drawVerticalScrollbar(listState),
state = listState,
) {
item {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(22.dp))
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
DisableSelection {
Header(articleWithFeed)
}
}
}
item {
Spacer(modifier = Modifier.height(22.dp))
AnimatedVisibility(
visible = isLoading,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column {
Spacer(modifier = Modifier.height(22.dp))
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(22.dp))
}
}
}
}
if (!isLoading) {
reader(
context = context,
link = articleWithFeed.article.link,
content = content
)
}
item {
Spacer(modifier = Modifier.height(128.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
}
@Composable
private fun BottomBar(
isShow: Boolean,
articleWithFeed: ArticleWithFeed?,
unreadOnClick: (afterIsUnread: Boolean) -> Unit = {},
starredOnClick: (afterIsStarred: Boolean) -> Unit = {},
fullContentOnClick: (afterIsFullContent: Boolean) -> Unit = {},
) {
articleWithFeed?.let {
AnimatedVisibility(
visible = isShow,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
ReadingBar(
modifier = Modifier.navigationBarsPadding(),
disabled = false,
isUnread = articleWithFeed.article.isUnread,
isStarred = articleWithFeed.article.isStarred,
isFullContent = articleWithFeed.feed.isFullContent,
unreadOnClick = unreadOnClick,
starredOnClick = starredOnClick,
fullContentOnClick = fullContentOnClick,
)
}
}
}

View File

@ -28,7 +28,9 @@ class ReadingViewModel @Inject constructor(
showLoading()
viewModelScope.launch {
_readingUiState.update {
it.copy(articleWithFeed = rssRepository.get().findArticleById(articleId))
it.copy(
articleWithFeed = rssRepository.get().findArticleById(articleId)
)
}
_readingUiState.value.articleWithFeed?.let {
if (it.feed.isFullContent) internalRenderFullContent()
@ -43,6 +45,7 @@ class ReadingViewModel @Inject constructor(
it.copy(
content = it.articleWithFeed?.article?.fullContent
?: it.articleWithFeed?.article?.rawDescription ?: "",
isFullContent = false
)
}
}
@ -53,7 +56,7 @@ class ReadingViewModel @Inject constructor(
}
}
suspend fun internalRenderFullContent() {
private suspend fun internalRenderFullContent() {
showLoading()
try {
_readingUiState.update {
@ -61,7 +64,8 @@ class ReadingViewModel @Inject constructor(
content = rssHelper.parseFullContent(
_readingUiState.value.articleWithFeed?.article?.link ?: "",
_readingUiState.value.articleWithFeed?.article?.title ?: ""
)
),
isFullContent = true
)
}
} catch (e: Exception) {
@ -133,6 +137,7 @@ class ReadingViewModel @Inject constructor(
data class ReadingUiState(
val articleWithFeed: ArticleWithFeed? = null,
val content: String? = null,
val isFullContent: Boolean = false,
val isLoading: Boolean = true,
val listState: LazyListState = LazyListState(),
)

View File

@ -0,0 +1,72 @@
package me.ash.reader.ui.page.home.reading
import RYExtensibleVisibility
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.R
import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.ext.share
@Composable
fun TopBar(
isShow: Boolean,
title: String? = "",
link: String? = "",
onClose: () -> Unit = {},
) {
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
contentAlignment = Alignment.TopCenter
) {
RYExtensibleVisibility(visible = isShow) {
SmallTopAppBar(
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
title = {},
navigationIcon = {
FeedbackIconButton(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.close),
tint = MaterialTheme.colorScheme.onSurface
) {
onClose()
}
},
actions = {
FeedbackIconButton(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.share),
tint = MaterialTheme.colorScheme.onSurface,
) {
context.share(title
?.takeIf { it.isNotBlank() }
?.let { it + "\n" } + link
)
}
}
)
}
}
}