Add mark all as read feature

This commit is contained in:
Ash 2022-04-08 04:35:28 +08:00
parent f95108fa67
commit aaf032332b
14 changed files with 304 additions and 114 deletions

View File

@ -6,46 +6,68 @@ import kotlinx.coroutines.flow.Flow
import me.ash.reader.data.entity.Article
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.entity.ImportantCount
import java.util.*
@Dao
interface ArticleDao {
@Query(
"""
UPDATE article SET isUnread = 0
UPDATE article SET isUnread = :isUnread
WHERE accountId = :accountId
AND isUnread = 1
AND date <= :before
AND date < :before
"""
)
suspend fun markAllAsRead(accountId: Int, before: Long)
suspend fun markAllAsRead(
accountId: Int,
isUnread: Boolean,
before: Date,
)
@Query(
"""
UPDATE article SET isUnread = 0
WHERE accountId = :accountId
AND isUnread = 1
AND date <= :before
AND feedId = :feedId
UPDATE article SET isUnread = :isUnread
WHERE feedId IN (
SELECT id FROM feed
WHERE groupId = :groupId
)
AND accountId = :accountId
AND date < :before
"""
)
suspend fun markAllAsReadByFeedId(accountId: Int, before: Long, feedId: String)
//
// @Query(
// """
// UPDATE article SET isUnread = 0
// WHERE accountId = :accountId
// AND isUnread = 1
// AND date <= :before
// AND feedId = :feedId
//
// SELECT * FROM `group` AS a, feed AS b, article AS c
// WHERE a.accountId = :accountId
// AND a.id = b.groupId
// AND b.groupId = :groupId
// AND c.feedId = b.id
// """
// )
// suspend fun markAllAsReadByGroupId(accountId: Int, before: Long, groupId: String)
suspend fun markAllAsReadByGroupId(
accountId: Int,
groupId: String,
isUnread: Boolean,
before: Date,
)
@Query(
"""
UPDATE article SET isUnread = :isUnread
WHERE feedId = :feedId
AND accountId = :accountId
AND date < :before
"""
)
suspend fun markAllAsReadByFeedId(
accountId: Int,
feedId: String,
isUnread: Boolean,
before: Date,
)
@Query(
"""
UPDATE article SET isUnread = :isUnread
WHERE id = :articleId
AND accountId = :accountId
"""
)
suspend fun markAsReadByArticleId(
accountId: Int,
articleId: String,
isUnread: Boolean,
)
@Query(
"""

View File

@ -38,6 +38,14 @@ abstract class AbstractRssRepository constructor(
abstract suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result
abstract suspend fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
before: Date?,
isUnread: Boolean,
)
fun doSync() {
workManager.enqueueUniquePeriodicWork(
SyncWorker.WORK_NAME,

View File

@ -125,6 +125,40 @@ class LocalRssRepository @Inject constructor(
}
}
override suspend fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
before: Date?,
isUnread: Boolean,
) {
val accountId = context.currentAccountId
when {
groupId != null -> {
articleDao.markAllAsReadByGroupId(
accountId = accountId,
groupId = groupId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
feedId != null -> {
articleDao.markAllAsReadByFeedId(
accountId = accountId,
feedId = feedId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
articleId != null -> {
articleDao.markAsReadByArticleId(accountId, articleId, isUnread)
}
else -> {
articleDao.markAllAsRead(accountId, isUnread, before ?: Date(Long.MAX_VALUE))
}
}
}
data class ArticleNotify(
val articles: List<Article>,
val isNotify: Boolean,

View File

@ -0,0 +1,48 @@
package me.ash.reader.ui.component
import androidx.compose.animation.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.google.accompanist.insets.LocalWindowInsets
@Composable
fun AnimatedPopup(
visible: Boolean = false,
absoluteY: Dp = Dp.Hairline,
absoluteX: Dp = Dp.Hairline,
onDismissRequest: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
val density = LocalDensity.current
val insets = LocalWindowInsets.current
Popup(
properties = PopupProperties(focusable = visible),
onDismissRequest = onDismissRequest,
popupPositionProvider = object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
return IntOffset(
x = with(density) { (absoluteX).roundToPx() },
y = with(density) { (absoluteY).roundToPx() + insets.statusBars.top }
)
}
},
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
content()
}
}
}

View File

@ -3,11 +3,13 @@ package me.ash.reader.ui.component
import androidx.compose.animation.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -28,8 +30,11 @@ fun DisplayText(
)
) {
Text(
modifier = Modifier.height(44.dp),
text = text,
style = MaterialTheme.typography.displaySmall,
style = MaterialTheme.typography.displaySmall.copy(
baselineShift = BaselineShift.Superscript
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -40,8 +45,11 @@ fun DisplayText(
exit = fadeOut() + shrinkVertically(),
) {
Text(
modifier = Modifier.height(16.dp),
text = desc,
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelMedium.copy(
baselineShift = BaselineShift.Superscript
),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@ -70,28 +70,30 @@ fun Modifier.combinedFeedbackClickable(
isSound: Boolean? = false,
onPressDown: (() -> Unit)? = null,
onPressUp: (() -> Unit)? = null,
onTap: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
): Modifier {
val view = LocalView.current
val interactionSource = remember { MutableInteractionSource() }
return if (onPressDown != null || onPressUp != null) {
return if (onPressDown != null || onPressUp != null || onTap != null) {
indication(interactionSource, LocalIndication.current)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
onPressDown?.let {
it()
if (isHaptic == true) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
val press = PressInteraction.Press(offset)
interactionSource.emit(press)
tryAwaitRelease()
onPressUp?.invoke()
interactionSource.emit(PressInteraction.Release(press))
it()
}
},
onTap = {
onPressUp?.let {
onTap?.let {
if (isHaptic == true) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
if (isSound == true) view.playSoundEffect(SoundEffectConstants.CLICK)
it()

View File

@ -64,7 +64,7 @@ fun FeedItem(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.outline),
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
) {}
Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp),

View File

@ -109,7 +109,6 @@ fun FeedsPage(
title = {},
navigationIcon = {
FeedbackIconButton(
isHaptic = false,
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.settings),
@ -120,7 +119,6 @@ fun FeedsPage(
},
actions = {
FeedbackIconButton(
isHaptic = false,
modifier = Modifier.rotate(if (isSyncing) angle else 0f),
imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.refresh),
@ -131,7 +129,6 @@ fun FeedsPage(
}
}
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
tint = MaterialTheme.colorScheme.onSurface,

View File

@ -3,8 +3,10 @@ package me.ash.reader.ui.page.home.flow
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
@ -16,7 +18,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
@ -71,28 +72,13 @@ fun FlowPage(
)
}
// LaunchedEffect(viewState.listState.isScrollInProgress) {
// Log.i("RLog", "isScrollInProgress: ${viewState.listState.isScrollInProgress}")
// if (viewState.listState.isScrollInProgress) {
// Log.i("RLog", "isScrollInProgress: ${true}")
// markAsRead = false
// }
// }
Scaffold(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.pointerInput(markAsRead) {
detectTapGestures {
markAsRead = false
}
},
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
topBar = {
SmallTopAppBar(
title = {},
navigationIcon = {
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface
@ -107,7 +93,6 @@ fun FlowPage(
exit = fadeOut() + shrinkVertically(),
) {
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.DoneAll,
contentDescription = stringResource(R.string.mark_all_as_read),
tint = if (markAsRead) {
@ -123,7 +108,6 @@ fun FlowPage(
}
}
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurface,
@ -162,17 +146,30 @@ fun FlowPage(
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column {
MarkAsReadBar()
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height((56 + 24 + 10).dp))
}
MarkAsReadBar(
visible = markAsRead,
absoluteY = if (isSyncing) (4 + 16 + 180).dp else 180.dp,
onDismissRequest = {
markAsRead = false
},
) {
markAsRead = false
flowViewModel.dispatch(
FlowViewAction.MarkAsRead(
groupId = filterState.group?.id,
feedId = filterState.feed?.id,
articleId = null,
markAsReadBefore = it,
)
)
}
}
generateArticleList(
context = context,
pagingItems = pagingItems,
) {
markAsRead = false
onItemClick(it)
}
item {
@ -190,14 +187,7 @@ fun FlowPage(
.height(60.dp)
.fillMaxWidth(),
filter = filterState.filter,
filterOnClick = {
markAsRead = false
onFilterChange(
filterState.copy(
filter = it
)
)
},
filterOnClick = { onFilterChange(filterState.copy(filter = it)) },
)
}
)

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.ui.page.home.FilterState
import java.util.*
import javax.inject.Inject
@HiltViewModel
@ -28,14 +29,11 @@ class FlowViewModel @Inject constructor(
is FlowViewAction.FetchData -> fetchData(action.filterState)
is FlowViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
is FlowViewAction.ScrollToItem -> scrollToItem(action.index)
is FlowViewAction.PeekSyncWork -> peekSyncWork()
}
}
private fun peekSyncWork() {
_viewState.update {
it.copy(
syncWorkInfo = rssRepository.get().peekWork()
is FlowViewAction.MarkAsRead -> markAsRead(
action.groupId,
action.feedId,
action.articleId,
action.markAsReadBefore,
)
}
}
@ -76,6 +74,37 @@ class FlowViewModel @Inject constructor(
it.copy(isRefreshing = isRefreshing)
}
}
private fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
markAsReadBefore: MarkAsReadBefore
) {
viewModelScope.launch {
rssRepository.get().markAsRead(
groupId = groupId,
feedId = feedId,
articleId = articleId,
before = when (markAsReadBefore) {
MarkAsReadBefore.All -> null
MarkAsReadBefore.OneDay -> Calendar.getInstance().apply {
time = Date()
add(Calendar.DAY_OF_MONTH, -1)
}.time
MarkAsReadBefore.ThreeDays -> Calendar.getInstance().apply {
time = Date()
add(Calendar.DAY_OF_MONTH, -3)
}.time
MarkAsReadBefore.SevenDays -> Calendar.getInstance().apply {
time = Date()
add(Calendar.DAY_OF_MONTH, -7)
}.time
},
isUnread = false,
)
}
}
}
data class ArticleViewState(
@ -99,5 +128,17 @@ sealed class FlowViewAction {
val index: Int
) : FlowViewAction()
object PeekSyncWork : FlowViewAction()
data class MarkAsRead(
val groupId: String?,
val feedId: String?,
val articleId: String?,
val markAsReadBefore: MarkAsReadBefore
) : FlowViewAction()
}
enum class MarkAsReadBefore {
SevenDays,
ThreeDays,
OneDay,
All,
}

View File

@ -1,5 +1,10 @@
package me.ash.reader.ui.page.home.flow
import android.view.HapticFeedbackConstants
import android.view.SoundEffectConstants
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -7,38 +12,68 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.ash.reader.R
import me.ash.reader.ui.component.AnimatedPopup
@Composable
fun MarkAsReadBar() {
Row(
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
fun MarkAsReadBar(
visible: Boolean = false,
absoluteY: Dp = Dp.Hairline,
onDismissRequest: () -> Unit = {},
onItemClick: (MarkAsReadBefore) -> Unit = {},
) {
val animated = remember { Animatable(absoluteY.value) }
LaunchedEffect(absoluteY) {
animated.animateTo(absoluteY.value, spring(stiffness = Spring.StiffnessMediumLow))
}
AnimatedPopup(
visible = visible,
absoluteY = animated.value.dp,
onDismissRequest = onDismissRequest,
) {
MarkAsReadBarItem(
modifier = Modifier.weight(1f),
text = stringResource(R.string.seven_days),
)
MarkAsReadBarItem(
modifier = Modifier.weight(1f),
text = stringResource(R.string.three_days),
)
MarkAsReadBarItem(
modifier = Modifier.weight(1f),
text = stringResource(R.string.one_day),
)
MarkAsReadBarItem(
modifier = Modifier.weight(2.5f),
text = stringResource(R.string.mark_all_as_read),
isPrimary = true,
)
Row(
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
MarkAsReadBarItem(
modifier = Modifier.width(56.dp),
text = stringResource(R.string.seven_days),
) {
onItemClick(MarkAsReadBefore.SevenDays)
}
MarkAsReadBarItem(
modifier = Modifier.width(56.dp),
text = stringResource(R.string.three_days),
) {
onItemClick(MarkAsReadBefore.ThreeDays)
}
MarkAsReadBarItem(
modifier = Modifier.width(56.dp),
text = stringResource(R.string.one_day),
) {
onItemClick(MarkAsReadBefore.OneDay)
}
MarkAsReadBarItem(
modifier = Modifier.weight(1f),
text = stringResource(R.string.mark_all_as_read),
isPrimary = true,
) {
onItemClick(MarkAsReadBefore.All)
}
}
}
}
@ -47,12 +82,19 @@ fun MarkAsReadBarItem(
modifier: Modifier = Modifier,
text: String,
isPrimary: Boolean = false,
onClick: () -> Unit = {},
) {
val view = LocalView.current
Surface(
modifier = modifier
.height(52.dp)
.height(56.dp)
.clip(RoundedCornerShape(16.dp))
.clickable { },
.clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
view.playSoundEffect(SoundEffectConstants.CLICK)
onClick()
},
tonalElevation = 2.dp,
shape = RoundedCornerShape(16.dp),
color = if (isPrimary) {
@ -70,7 +112,7 @@ fun MarkAsReadBarItem(
text = text,
style = MaterialTheme.typography.titleSmall,
color = if (isPrimary) {
MaterialTheme.colorScheme.onPrimaryContainer
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.secondary
},

View File

@ -143,7 +143,6 @@ private fun TopBar(
title = {},
navigationIcon = {
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.close),
tint = MaterialTheme.colorScheme.onSurface
@ -156,7 +155,6 @@ private fun TopBar(
actions = {
if (isShowActions) {
FeedbackIconButton(
isHaptic = false,
modifier = Modifier.size(22.dp),
imageVector = Icons.Outlined.Headphones,
contentDescription = stringResource(R.string.mark_all_as_read),
@ -164,7 +162,6 @@ private fun TopBar(
) {
}
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurface,

View File

@ -79,7 +79,7 @@ class ReadViewModel @Inject constructor(
private fun markUnread(isUnread: Boolean) {
val articleWithFeed = _viewState.value.articleWithFeed ?: return
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
_viewState.update {
it.copy(
articleWithFeed = articleWithFeed.copy(
@ -89,10 +89,12 @@ class ReadViewModel @Inject constructor(
)
)
}
rssRepository.get().updateArticleInfo(
articleWithFeed.article.copy(
isUnread = isUnread
)
rssRepository.get().markAsRead(
groupId = null,
feedId = null,
articleId = _viewState.value.articleWithFeed!!.article.id,
before = null,
isUnread = isUnread,
)
}
}

View File

@ -33,7 +33,6 @@ fun SettingsPage(
title = {},
navigationIcon = {
FeedbackIconButton(
isHaptic = false,
imageVector = Icons.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface