feat(ui): long press on an item to show context menu (#613)

* feat(ui): long press on an item to show context menu

* feat(ui): add handy enter-exit transition to drop down menu

* feat(ui): implement share action

* feat(ui): polish the enter/exit transition

* fix(ui): RTL walkaround

* feat(ui): dropdown menu style tweaks

* feat(ui): mark above as read & mark below as read

* feat(fever): update read status by id set

* fix: use `batchMarkAsRead`

* fix: disable `onMarkAboveAsRead` for the first item
This commit is contained in:
junkfood 2024-03-10 21:15:00 +08:00 committed by GitHub
parent db65c3dca5
commit 53523e44ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 939 additions and 91 deletions

View File

@ -155,9 +155,9 @@ dependencies {
kapt "androidx.room:room-compiler:$room"
// https://developer.android.com/jetpack/androidx/releases/paging
implementation "androidx.paging:paging-common:$paging"
implementation "androidx.paging:paging-runtime:$paging"
implementation "androidx.paging:paging-compose:1.0.0-alpha14"
implementation "androidx.paging:paging-common-ktx:$paging"
implementation "androidx.paging:paging-runtime-ktx:$paging"
implementation "androidx.paging:paging-compose:$paging"
// https://developer.android.com/jetpack/androidx/releases/paging
implementation "androidx.browser:browser:1.5.0"

View File

@ -8,6 +8,8 @@ import androidx.work.WorkManager
import com.rometools.rome.feed.synd.SyndFeed
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import me.ash.reader.R
@ -323,6 +325,7 @@ class FeverRssService @Inject constructor(
}
}
override suspend fun batchMarkAsRead(articleIds: Set<String>, isUnread: Boolean) {
super.batchMarkAsRead(articleIds, isUnread)
val feverAPI = getFeverAPI()

View File

@ -108,15 +108,17 @@ class GoogleReaderRssService @Inject constructor(
destCategoryId = groupId.dollarLast(),
destFeedName = searchedFeed.title!!
)
feedDao.insert(Feed(
id = accountId.spacerDollar(feedId),
name = searchedFeed.title!!,
url = feedLink,
groupId = groupId,
accountId = accountId,
isNotification = isNotification,
isFullContent = isFullContent,
))
feedDao.insert(
Feed(
id = accountId.spacerDollar(feedId),
name = searchedFeed.title!!,
url = feedLink,
groupId = groupId,
accountId = accountId,
isNotification = isNotification,
isFullContent = isFullContent,
)
)
// TODO: When users need to subscribe to multiple feeds continuously, this makes them uncomfortable.
// It is necessary to make syncWork support synchronizing individual specified feeds.
// super.doSyncOneTime()
@ -233,11 +235,13 @@ class GoogleReaderRssService @Inject constructor(
// Handle folders
groupDao.insertOrUpdate(
listOf(Group(
id = groupId,
name = category.label!!,
accountId = accountId,
))
listOf(
Group(
id = groupId,
name = category.label!!,
accountId = accountId,
)
)
)
groupIds.add(groupId)
@ -418,8 +422,10 @@ class GoogleReaderRssService @Inject constructor(
fullContent = it.summary?.content ?: "",
img = rssHelper.findImg(it.summary?.content ?: ""),
link = findArticleURL(it),
feedId = accountId.spacerDollar(it.origin?.streamId?.ofFeedStreamIdToId()
?: feedIds.first()),
feedId = accountId.spacerDollar(
it.origin?.streamId?.ofFeedStreamIdToId()
?: feedIds.first()
),
accountId = accountId,
isUnread = unreadIds.contains(articleId),
isStarred = starredIds.contains(articleId),
@ -448,10 +454,12 @@ class GoogleReaderRssService @Inject constructor(
if (before == null) {
articleDao.queryMetadataByGroupIdWhenIsUnread(accountId, groupId, !isUnread)
} else {
articleDao.queryMetadataByGroupIdWhenIsUnread(accountId,
articleDao.queryMetadataByGroupIdWhenIsUnread(
accountId,
groupId,
!isUnread,
before)
before
)
}.map { it.id.dollarLast() }
}

View File

@ -0,0 +1,96 @@
package me.ash.reader.ui.component.menu
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
/**
* <a href="https://m3.material.io/components/menus/overview" class="external" target="_blank">Material Design dropdown menu</a>.
*
* Menus display a list of choices on a temporary surface. They appear when users interact with a
* button, action, or other control.
*
* ![Dropdown menu image](https://developer.android.com/images/reference/androidx/compose/material3/menu.png)
*
* A [DropdownMenu] behaves similarly to a [Popup], and will use the position of the parent layout
* to position itself on screen. Commonly a [DropdownMenu] will be placed in a [Box] with a sibling
* that will be used as the 'anchor'. Note that a [DropdownMenu] by itself will not take up any
* space in a layout, as the menu is displayed in a separate window, on top of other content.
*
* The [content] of a [DropdownMenu] will typically be [DropdownMenuItem]s, as well as custom
* content. Using [DropdownMenuItem]s will result in a menu that matches the Material
* specification for menus. Also note that the [content] is placed inside a scrollable [Column],
* so using a [LazyColumn] as the root layout inside [content] is unsupported.
*
* [onDismissRequest] will be called when the menu should close - for example when there is a
* tap outside the menu, or when the back key is pressed.
*
* [DropdownMenu] changes its positioning depending on the available space, always trying to be
* fully visible. Depending on layout direction, first it will try to align its start to the start
* of its parent, then its end to the end of its parent, and then to the edge of the window.
* Vertically, it will try to align its top to the bottom of its parent, then its bottom to top of
* its parent, and then to the edge of the window.
*
* An [offset] can be provided to adjust the positioning of the menu for cases when the layout
* bounds of its parent do not coincide with its visual bounds.
*
*
* @param expanded whether the menu is expanded or not
* @param onDismissRequest called when the user requests to dismiss the menu, such as by tapping
* outside the menu's bounds
* @param modifier [Modifier] to be applied to the menu's content
* @param offset [DpOffset] from the original position of the menu. The offset respects the
* [LayoutDirection], so the offset's x position will be added in LTR and subtracted in RTL.
* @param scrollState a [ScrollState] to used by the menu's content for items vertical scrolling
* @param properties [PopupProperties] for further customization of this popup's behavior
* @param content the content of this dropdown menu, typically a [DropdownMenuItem]
*/
@Composable
fun AnimatedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
val expandedState = remember { MutableTransitionState(false) }
expandedState.targetState = expanded
if (expandedState.currentState || expandedState.targetState || !expandedState.isIdle) {
val density = LocalDensity.current
val popupPositionProvider = remember(offset, density) {
DropdownMenuPositionProvider(
offset,
density
)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties
) {
DropdownMenuContent(
expandedState = expandedState,
scrollState = scrollState,
modifier = modifier,
content = content
)
}
}
}

View File

@ -0,0 +1,486 @@
package me.ash.reader.ui.component.menu
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.window.PopupPositionProvider
import me.ash.reader.domain.model.constant.ElevationTokens
import me.ash.reader.ui.motion.EmphasizedAccelerate
import me.ash.reader.ui.motion.EmphasizedDecelerate
import me.ash.reader.ui.motion.EnterDuration
import me.ash.reader.ui.motion.ExitDuration
import kotlin.math.max
import kotlin.math.min
/**
* Interfaces for positioning a menu within a window. This is the same purpose as the interface
* [PopupPositionProvider], except [Vertical] and [Horizontal] separate out the positioning logic
* for each direction individually.
*/
@Stable
internal object MenuPosition {
/**
* An interface to calculate the vertical position of a menu with respect to its anchor and
* window. The returned y-coordinate is relative to the window.
*
* @see PopupPositionProvider
*/
@Stable
fun interface Vertical {
fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuHeight: Int,
): Int
}
/**
* An interface to calculate the horizontal position of a menu with respect to its anchor,
* window, and layout direction. The returned x-coordinate is relative to the window.
*
* @see PopupPositionProvider
*/
@Stable
fun interface Horizontal {
fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuWidth: Int,
layoutDirection: LayoutDirection,
): Int
}
/**
* Returns a [MenuPosition.Horizontal] which aligns the start of the menu to the start of the
* anchor.
*
* The given [offset] is [LayoutDirection]-aware. It will be added to the resulting x position
* for [LayoutDirection.Ltr] and subtracted for [LayoutDirection.Rtl].
*/
fun startToAnchorStart(offset: Int = 0): Horizontal = AnchorAlignmentOffsetPosition.Horizontal(
menuAlignment = Alignment.Start,
anchorAlignment = Alignment.Start,
offset = offset,
)
/**
* Returns a [MenuPosition.Horizontal] which aligns the end of the menu to the end of the
* anchor.
*
* The given [offset] is [LayoutDirection]-aware. It will be added to the resulting x position
* for [LayoutDirection.Ltr] and subtracted for [LayoutDirection.Rtl].
*/
fun endToAnchorEnd(offset: Int = 0): Horizontal = AnchorAlignmentOffsetPosition.Horizontal(
menuAlignment = Alignment.End,
anchorAlignment = Alignment.End,
offset = offset,
)
/**
* Returns a [MenuPosition.Horizontal] which aligns the left of the menu to the left of the
* window.
*
* The resulting x position will be coerced so that the menu remains within the area inside the
* given [margin] from the left and right edges of the window.
*/
fun leftToWindowLeft(margin: Int = 0): Horizontal = WindowAlignmentMarginPosition.Horizontal(
alignment = AbsoluteAlignment.Left,
margin = margin,
)
/**
* Returns a [MenuPosition.Horizontal] which aligns the right of the menu to the right of the
* window.
*
* The resulting x position will be coerced so that the menu remains within the area inside the
* given [margin] from the left and right edges of the window.
*/
fun rightToWindowRight(margin: Int = 0): Horizontal = WindowAlignmentMarginPosition.Horizontal(
alignment = AbsoluteAlignment.Right,
margin = margin,
)
/**
* Returns a [MenuPosition.Vertical] which aligns the top of the menu to the bottom of the
* anchor.
*/
fun topToAnchorBottom(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical(
menuAlignment = Alignment.Top,
anchorAlignment = Alignment.Bottom,
offset = offset,
)
/**
* Returns a [MenuPosition.Vertical] which aligns the bottom of the menu to the top of the
* anchor.
*/
fun bottomToAnchorTop(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical(
menuAlignment = Alignment.Bottom,
anchorAlignment = Alignment.Top,
offset = offset,
)
/**
* Returns a [MenuPosition.Vertical] which aligns the center of the menu to the top of the
* anchor.
*/
fun centerToAnchorTop(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical(
menuAlignment = Alignment.CenterVertically,
anchorAlignment = Alignment.Top,
offset = offset,
)
/**
* Returns a [MenuPosition.Vertical] which aligns the top of the menu to the top of the
* window.
*
* The resulting y position will be coerced so that the menu remains within the area inside the
* given [margin] from the top and bottom edges of the window.
*/
fun topToWindowTop(margin: Int = 0): Vertical = WindowAlignmentMarginPosition.Vertical(
alignment = Alignment.Top,
margin = margin,
)
/**
* Returns a [MenuPosition.Vertical] which aligns the bottom of the menu to the bottom of the
* window.
*
* The resulting y position will be coerced so that the menu remains within the area inside the
* given [margin] from the top and bottom edges of the window.
*/
fun bottomToWindowBottom(margin: Int = 0): Vertical = WindowAlignmentMarginPosition.Vertical(
alignment = Alignment.Bottom,
margin = margin,
)
}
@Immutable
internal object AnchorAlignmentOffsetPosition {
/**
* A [MenuPosition.Horizontal] which horizontally aligns the given [menuAlignment] with the
* given [anchorAlignment].
*
* The given [offset] is [LayoutDirection]-aware. It will be added to the resulting x position
* for [LayoutDirection.Ltr] and subtracted for [LayoutDirection.Rtl].
*/
@Immutable
data class Horizontal(
private val menuAlignment: Alignment.Horizontal,
private val anchorAlignment: Alignment.Horizontal,
private val offset: Int,
) : MenuPosition.Horizontal {
override fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuWidth: Int,
layoutDirection: LayoutDirection,
): Int {
val anchorAlignmentOffset = anchorAlignment.align(
size = 0,
space = anchorBounds.width,
layoutDirection = layoutDirection,
)
val menuAlignmentOffset = -menuAlignment.align(
size = 0,
space = menuWidth,
layoutDirection,
)
val resolvedOffset = if (layoutDirection == LayoutDirection.Ltr) offset else -offset
return anchorBounds.left + anchorAlignmentOffset + menuAlignmentOffset + resolvedOffset
}
}
/**
* A [MenuPosition.Vertical] which vertically aligns the given [menuAlignment] with the given
* [anchorAlignment].
*/
@Immutable
data class Vertical(
private val menuAlignment: Alignment.Vertical,
private val anchorAlignment: Alignment.Vertical,
private val offset: Int,
) : MenuPosition.Vertical {
override fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuHeight: Int,
): Int {
val anchorAlignmentOffset = anchorAlignment.align(
size = 0,
space = anchorBounds.height,
)
val menuAlignmentOffset = -menuAlignment.align(
size = 0,
space = menuHeight,
)
return anchorBounds.top + anchorAlignmentOffset + menuAlignmentOffset + offset
}
}
}
@Immutable
internal object WindowAlignmentMarginPosition {
/**
* A [MenuPosition.Horizontal] which horizontally aligns the menu within the window according
* to the given [alignment].
*
* The resulting x position will be coerced so that the menu remains within the area inside the
* given [margin] from the left and right edges of the window. If this is not possible, i.e.,
* the menu is too wide, then it is centered horizontally instead.
*/
@Immutable
data class Horizontal(
private val alignment: Alignment.Horizontal,
private val margin: Int,
) : MenuPosition.Horizontal {
override fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuWidth: Int,
layoutDirection: LayoutDirection,
): Int {
if (menuWidth >= windowSize.width - 2 * margin) {
return Alignment.CenterHorizontally.align(
size = menuWidth,
space = windowSize.width,
layoutDirection = layoutDirection,
)
}
val x = alignment.align(
size = menuWidth,
space = windowSize.width,
layoutDirection = layoutDirection,
)
return x.coerceIn(margin, windowSize.width - margin - menuWidth)
}
}
/**
* A [MenuPosition.Vertical] which vertically aligns the menu within the window according to
* the given [alignment].
*
* The resulting y position will be coerced so that the menu remains within the area inside the
* given [margin] from the top and bottom edges of the window. If this is not possible, i.e.,
* the menu is too tall, then it is centered vertically instead.
*/
@Immutable
data class Vertical(
private val alignment: Alignment.Vertical,
private val margin: Int,
) : MenuPosition.Vertical {
override fun position(
anchorBounds: IntRect,
windowSize: IntSize,
menuHeight: Int,
): Int {
if (menuHeight >= windowSize.height - 2 * margin) {
return Alignment.CenterVertically.align(
size = menuHeight,
space = windowSize.height,
)
}
val y = alignment.align(
size = menuHeight,
space = windowSize.height,
)
return y.coerceIn(margin, windowSize.height - margin - menuHeight)
}
}
}
/**
* Calculates the position of a Material [DropdownMenu].
*/
@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val verticalMargin: Int = with(density) { MenuVerticalMargin.roundToPx() },
val onPositionCalculated: (anchorBounds: IntRect, menuBounds: IntRect) -> Unit = { _, _ -> }
) : PopupPositionProvider {
// Horizontal position
private val startToAnchorStart: MenuPosition.Horizontal
private val endToAnchorEnd: MenuPosition.Horizontal
private val leftToWindowLeft: MenuPosition.Horizontal
private val rightToWindowRight: MenuPosition.Horizontal
// Vertical position
private val topToAnchorBottom: MenuPosition.Vertical
private val bottomToAnchorTop: MenuPosition.Vertical
private val centerToAnchorTop: MenuPosition.Vertical
private val topToWindowTop: MenuPosition.Vertical
private val bottomToWindowBottom: MenuPosition.Vertical
init {
// Horizontal position
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
startToAnchorStart = MenuPosition.startToAnchorStart(offset = contentOffsetX)
endToAnchorEnd = MenuPosition.endToAnchorEnd(offset = contentOffsetX)
leftToWindowLeft = MenuPosition.leftToWindowLeft(margin = 0)
rightToWindowRight = MenuPosition.rightToWindowRight(margin = 0)
// Vertical position
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
topToAnchorBottom = MenuPosition.topToAnchorBottom(offset = contentOffsetY)
bottomToAnchorTop = MenuPosition.bottomToAnchorTop(offset = contentOffsetY)
centerToAnchorTop = MenuPosition.centerToAnchorTop(offset = contentOffsetY)
topToWindowTop = MenuPosition.topToWindowTop(margin = verticalMargin)
bottomToWindowBottom = MenuPosition.bottomToWindowBottom(margin = verticalMargin)
}
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val xCandidates = listOf(
startToAnchorStart, endToAnchorEnd, if (anchorBounds.center.x < windowSize.width / 2) {
leftToWindowLeft
} else {
rightToWindowRight
}
).fastMap {
it.position(
anchorBounds = anchorBounds,
windowSize = windowSize,
menuWidth = popupContentSize.width,
layoutDirection = layoutDirection
)
}
val x = xCandidates.fastFirstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: xCandidates.last()
val yCandidates = listOf(
topToAnchorBottom,
bottomToAnchorTop,
centerToAnchorTop,
if (anchorBounds.center.y < windowSize.height / 2) {
topToWindowTop
} else {
bottomToWindowBottom
}
).fastMap {
it.position(
anchorBounds = anchorBounds,
windowSize = windowSize,
menuHeight = popupContentSize.height
)
}
val y = yCandidates.fastFirstOrNull {
it >= verticalMargin && it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: yCandidates.last()
val menuOffset = IntOffset(x, y)
onPositionCalculated(/* anchorBounds = */anchorBounds,/* menuBounds = */
IntRect(offset = menuOffset, size = popupContentSize)
)
return menuOffset
}
}
// The shadow disappears when the surface is fading out, delay the animation to make it less noticeable
private const val FadeOutDuration = 80
@Composable
fun DropdownMenuContent(
expandedState: MutableTransitionState<Boolean>,
scrollState: ScrollState,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
AnimatedVisibility(
visibleState = expandedState, label = "", enter = EnterTransition.None, exit = fadeOut(
animationSpec = tween(
delayMillis = ExitDuration - FadeOutDuration,
durationMillis = FadeOutDuration,
easing = LinearEasing
)
), modifier = modifier
) {
Surface(
modifier = Modifier,
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.surface,
tonalElevation = ElevationTokens.Level2.dp,
shadowElevation = ElevationTokens.Level1.dp
) {
AnimatedVisibility(
visibleState = expandedState, label = "", enter = fadeIn(
animationSpec = tween(
durationMillis = EnterDuration, easing = EmphasizedDecelerate
)
) + expandVertically(
animationSpec = tween(
durationMillis = EnterDuration, easing = EmphasizedDecelerate
),
expandFrom = Alignment.Top,
) + slideInVertically(
animationSpec = tween(
durationMillis = EnterDuration, easing = EmphasizedDecelerate
),
initialOffsetY = { -it / 10 },
), exit = fadeOut(
animationSpec = tween(
// Why ???
durationMillis = ExitDuration - 20,
easing = EmphasizedAccelerate
)
) + shrinkVertically(
animationSpec = tween(
durationMillis = ExitDuration, easing = EmphasizedAccelerate
),
shrinkTowards = Alignment.Top,
) + slideOutVertically(animationSpec = tween(
durationMillis = ExitDuration, easing = EmphasizedAccelerate
), targetOffsetY = { -it / 10 }), modifier = Modifier
) {
Column(
modifier = Modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(scrollState), content = content
)
}
}
}
}
// Size defaults.
internal val MenuVerticalMargin = 48.dp
internal val DropdownMenuVerticalPadding = 8.dp

View File

@ -0,0 +1,10 @@
package me.ash.reader.ui.motion
import androidx.compose.animation.core.CubicBezierEasing
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
const val EnterDuration = 400
const val ExitDuration = 200

View File

@ -1,14 +1,15 @@
package me.ash.reader.ui.page.home.flow
import android.util.Log
import android.view.HapticFeedbackConstants
import androidx.compose.animation.Animatable
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -20,43 +21,55 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.FiberManualRecord
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.CheckCircleOutline
import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import coil.size.Precision
import coil.size.Scale
import me.ash.reader.R
import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.domain.model.constant.ElevationTokens
import me.ash.reader.infrastructure.preference.FlowArticleReadIndicatorPreference
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeEndAction
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeStartAction
@ -71,14 +84,19 @@ import me.ash.reader.infrastructure.preference.SwipeStartActionPreference
import me.ash.reader.ui.component.FeedIcon
import me.ash.reader.ui.component.base.RYAsyncImage
import me.ash.reader.ui.component.base.SIZE_1000
import me.ash.reader.ui.component.menu.AnimatedDropdownMenu
import me.ash.reader.ui.ext.surfaceColorAtElevation
import me.ash.reader.ui.page.settings.color.flow.generateArticleWithFeedPreview
import me.ash.reader.ui.theme.Shape20
import me.ash.reader.ui.theme.palette.onDark
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ArticleItem(
modifier: Modifier = Modifier,
articleWithFeed: ArticleWithFeed,
onClick: (ArticleWithFeed) -> Unit = {},
onLongClick: (() -> Unit)? = null
) {
val articleListFeedIcon = LocalFlowArticleListFeedIcon.current
val articleListFeedName = LocalFlowArticleListFeedName.current
@ -88,10 +106,13 @@ fun ArticleItem(
val articleListReadIndicator = LocalFlowArticleListReadIndicator.current
Column(
modifier = Modifier
modifier = modifier
.padding(horizontal = 12.dp)
.clip(Shape20)
.clickable { onClick(articleWithFeed) }
.combinedClickable(
onClick = { onClick(articleWithFeed) },
onLongClick = onLongClick,
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.alpha(articleWithFeed.article.run {
when (articleListReadIndicator) {
@ -214,31 +235,47 @@ fun ArticleItem(
}
private const val PositionalThresholdFraction = 0.15f
private const val SwipeActionDelay = 300L
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun SwipeableArticleItem(
articleWithFeed: ArticleWithFeed,
isFilterUnread: Boolean,
articleListTonalElevation: Int,
isFilterUnread: Boolean = false,
articleListTonalElevation: Int = 0,
onClick: (ArticleWithFeed) -> Unit = {},
isScrollInProgress: () -> Boolean = { false },
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
isSwipeEnabled: () -> Boolean = { false },
isMenuEnabled: Boolean = true,
onToggleStarred: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onToggleRead: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
onShare: ((ArticleWithFeed) -> Unit)? = null,
) {
val swipeToStartAction = LocalArticleListSwipeStartAction.current
val swipeToEndAction = LocalArticleListSwipeEndAction.current
val onSwipeEndToStart = when (swipeToStartAction) {
SwipeStartActionPreference.None -> null
SwipeStartActionPreference.ToggleRead -> onToggleRead
SwipeStartActionPreference.ToggleStarred -> onToggleStarred
}
val onSwipeStartToEnd = when (swipeToEndAction) {
SwipeEndActionPreference.None -> null
SwipeEndActionPreference.ToggleRead -> onToggleRead
SwipeEndActionPreference.ToggleStarred -> onToggleStarred
}
val density = LocalDensity.current
val confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = {
when (it) {
SwipeToDismissBoxValue.StartToEnd -> {
onSwipeStartToEnd?.invoke(articleWithFeed)
onSwipeStartToEnd?.invoke(articleWithFeed, SwipeActionDelay)
swipeToEndAction == SwipeEndActionPreference.ToggleRead && isFilterUnread
}
SwipeToDismissBoxValue.EndToStart -> {
onSwipeEndToStart?.invoke(articleWithFeed)
onSwipeEndToStart?.invoke(articleWithFeed, SwipeActionDelay)
swipeToStartAction == SwipeStartActionPreference.ToggleRead && isFilterUnread
}
@ -271,25 +308,38 @@ fun SwipeableArticleItem(
)
}
val view = LocalView.current
var isActive by remember(articleWithFeed) { mutableStateOf(false) }
var isThresholdPassed by remember(articleWithFeed) { mutableStateOf(false) }
LaunchedEffect(swipeState.progress > PositionalThresholdFraction) {
if (swipeState.progress > PositionalThresholdFraction && swipeState.targetValue != SwipeToDismissBoxValue.Settled) {
isActive = true
view.performHapticFeedback(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE)
isThresholdPassed = true
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
} else {
isActive = false
isThresholdPassed = false
}
}
var expanded by remember { mutableStateOf(false) }
val onLongClick = if (isMenuEnabled) {
{
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
expanded = true
}
} else {
null
}
var menuOffset by remember { mutableStateOf(Offset(0f, 0f)) }
SwipeToDismissBox(
state = swipeState,
enabled = !isScrollInProgress(),
enabled = !isSwipeEnabled(),
/*** create dismiss alert background box */
backgroundContent = {
SwipeToDismissBoxBackgroundContent(
direction = swipeState.dismissDirection,
isActive = isActive,
isActive = isThresholdPassed,
isStarred = articleWithFeed.article.isStarred,
isRead = !articleWithFeed.article.isUnread
)
@ -299,13 +349,52 @@ fun SwipeableArticleItem(
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(expanded) {
awaitEachGesture {
while (true) {
awaitFirstDown(requireUnconsumed = false).let {
menuOffset = it.position
}
}
}
}
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
articleListTonalElevation.dp
) onDark MaterialTheme.colorScheme.surface
)
.wrapContentSize()
) {
ArticleItem(articleWithFeed, onClick)
ArticleItem(
articleWithFeed = articleWithFeed,
onClick = onClick,
onLongClick = onLongClick
)
with(articleWithFeed.article) {
if (isMenuEnabled) {
AnimatedDropdownMenu(
modifier = Modifier.padding(12.dp),
expanded = expanded,
onDismissRequest = { expanded = false },
offset = density.run {
if (LocalLayoutDirection.current == LayoutDirection.Ltr)
DpOffset(menuOffset.x.toDp(), 0.dp)
else DpOffset(0.dp, 0.dp)
},
) {
ArticleItemMenuContent(
articleWithFeed = articleWithFeed,
isStarred = isStarred,
isRead = !isUnread,
onToggleStarred = onToggleStarred,
onToggleRead = onToggleRead,
onMarkAboveAsRead = onMarkAboveAsRead,
onMarkBelowAsRead = onMarkBelowAsRead,
onShare = onShare
) { expanded = false }
}
}
}
}
},
/*** Set Direction to dismiss */
@ -413,7 +502,7 @@ private fun RowScope.SwipeToDismissBoxBackgroundContent(
imageVector?.let {
Icon(
imageVector = it,
contentDescription = null,
contentDescription = text,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.align(Alignment.CenterHorizontally)
@ -422,6 +511,117 @@ private fun RowScope.SwipeToDismissBoxBackgroundContent(
}
}
}
}
@Composable
fun ArticleItemMenuContent(
articleWithFeed: ArticleWithFeed,
iconSize: DpSize = DpSize(width = 20.dp, height = 20.dp),
isStarred: Boolean = false,
isRead: Boolean = false,
onToggleStarred: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onToggleRead: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
onShare: ((ArticleWithFeed) -> Unit)? = null,
onItemClick: (() -> Unit)? = null,
) {
val starImageVector =
remember(isStarred) { if (isStarred) Icons.Outlined.StarOutline else Icons.Rounded.Star }
val readImageVector =
remember(isRead) { if (isRead) Icons.Outlined.FiberManualRecord else Icons.Rounded.FiberManualRecord }
val starText =
stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred)
val readText =
stringResource(if (isRead) R.string.mark_as_unread else R.string.mark_as_read)
DropdownMenuItem(text = { Text(text = readText) }, onClick = {
onToggleRead(articleWithFeed, 0)
onItemClick?.invoke()
}, leadingIcon = {
Icon(
imageVector = readImageVector,
contentDescription = null,
modifier = Modifier.size(iconSize)
)
})
DropdownMenuItem(
text = { Text(text = starText) },
onClick = {
onToggleStarred(articleWithFeed, 0)
onItemClick?.invoke()
},
leadingIcon = {
Icon(
imageVector = starImageVector,
contentDescription = null,
modifier = Modifier.size(iconSize)
)
})
if (onMarkAboveAsRead != null || onMarkBelowAsRead != null) {
HorizontalDivider()
}
onMarkAboveAsRead?.let {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.mark_above_as_read)) },
onClick = {
onMarkAboveAsRead(articleWithFeed)
onItemClick?.invoke()
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(iconSize)
)
})
}
onMarkBelowAsRead?.let {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.mark_below_as_read)) },
onClick = {
onMarkBelowAsRead(articleWithFeed)
onItemClick?.invoke()
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = null,
modifier = Modifier.size(iconSize)
)
})
}
onShare?.let {
HorizontalDivider()
DropdownMenuItem(text = { Text(text = stringResource(id = R.string.share)) }, onClick = {
onShare(articleWithFeed)
onItemClick?.invoke()
}, leadingIcon = {
Icon(
imageVector = Icons.Rounded.Share, contentDescription = null,
modifier = Modifier.size(iconSize)
)
})
}
}
@Preview
@Composable
fun MenuContentPreview() {
MaterialTheme {
Surface() {
Column(modifier = Modifier.padding()) {
ArticleItemMenuContent(
articleWithFeed = generateArticleWithFeedPreview(),
onMarkBelowAsRead = {},
onMarkAboveAsRead = {},
onShare = {})
}
}
}
}

View File

@ -18,34 +18,32 @@ fun LazyListScope.ArticleList(
isShowFeedIcon: Boolean,
isShowStickyHeader: Boolean,
articleListTonalElevation: Int,
isScrollInProgress: () -> Boolean = { false },
isSwipeEnabled: () -> Boolean = { false },
isMenuEnabled: Boolean = true,
onClick: (ArticleWithFeed) -> Unit = {},
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
onToggleStarred: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onToggleRead: (ArticleWithFeed, Long) -> Unit = { _, _ -> },
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
onShare: ((ArticleWithFeed) -> Unit)? = null,
) {
for (index in 0 until pagingItems.itemCount) {
when (val item = pagingItems[index]) {
is ArticleFlowItem.Article -> {
item(key = item.articleWithFeed.article.id) {
// if (item.articleWithFeed.article.isUnread) {
SwipeableArticleItem(
articleWithFeed = item.articleWithFeed,
isFilterUnread = isFilterUnread,
articleListTonalElevation = articleListTonalElevation,
onClick = { onClick(it) },
isScrollInProgress = isScrollInProgress,
onSwipeStartToEnd = onSwipeStartToEnd,
onSwipeEndToStart = onSwipeEndToStart
onClick = onClick,
isSwipeEnabled = isSwipeEnabled,
isMenuEnabled = isMenuEnabled,
onToggleStarred = onToggleStarred,
onToggleRead = onToggleRead,
onMarkAboveAsRead = if (index == 1) null else onMarkAboveAsRead, // index == 0 -> ArticleFlowItem.Date
onMarkBelowAsRead = if (index == pagingItems.itemCount - 1) null else onMarkBelowAsRead,
onShare = onShare
)
/* } else {
// Currently we don't have swipe left to mark as unread,
// so [SwipeableArticleItem] is not necessary for read articles.
ArticleItem(
articleWithFeed = (pagingItems[index] as ArticleFlowItem.Article).articleWithFeed,
) {
onClick(it)
}
}*/
}
}

View File

@ -12,6 +12,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
@ -30,6 +32,7 @@ import me.ash.reader.infrastructure.preference.*
import me.ash.reader.ui.component.FilterBar
import me.ash.reader.ui.component.base.*
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.share
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.home.HomeViewModel
@ -52,8 +55,7 @@ fun FlowPage(
val filterBarFilled = LocalFlowFilterBarFilled.current
val filterBarPadding = LocalFlowFilterBarPadding.current
val filterBarTonalElevation = LocalFlowFilterBarTonalElevation.current
val swipeToStartAction = LocalArticleListSwipeStartAction.current
val swipeToEndAction = LocalArticleListSwipeEndAction.current
val context = LocalContext.current
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
val flowUiState = flowViewModel.flowUiState.collectAsStateValue()
@ -79,43 +81,55 @@ fun FlowPage(
onDispose { homeViewModel.syncWorkLiveData.removeObservers(owner) }
}
val onToggleStarred: (ArticleWithFeed) -> Unit = remember {
{
val onToggleStarred: (ArticleWithFeed, Long) -> Unit = remember {
{ article, delay ->
flowViewModel.updateStarredStatus(
articleId = it.article.id,
isStarred = !it.article.isStarred,
withDelay = 300
articleId = article.article.id,
isStarred = !article.article.isStarred,
withDelay = delay
)
}
}
val onToggleRead: (ArticleWithFeed) -> Unit = remember {
{
val onToggleRead: (ArticleWithFeed, Long) -> Unit = remember {
{ article, delay ->
flowViewModel.updateReadStatus(
groupId = null,
feedId = null,
articleId = it.article.id,
articleId = article.article.id,
conditions = MarkAsReadConditions.All,
isUnread = !it.article.isUnread,
withDelay = 300
isUnread = !article.article.isUnread,
withDelay = delay
)
}
}
val onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = remember {
{
flowViewModel.markAsReadFromListByDate(
date = it.article.date,
isBefore = false,
lazyPagingItems = pagingItems
)
}
}
val onSwipeEndToStart = remember(swipeToStartAction) {
when (swipeToStartAction) {
SwipeStartActionPreference.None -> null
SwipeStartActionPreference.ToggleRead -> onToggleRead
SwipeStartActionPreference.ToggleStarred -> onToggleStarred
val onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = remember {
{
flowViewModel.markAsReadFromListByDate(
date = it.article.date,
isBefore = true,
lazyPagingItems = pagingItems
)
}
}
val onSwipeStartToEnd = remember(swipeToEndAction) {
when (swipeToEndAction) {
SwipeEndActionPreference.None -> null
SwipeEndActionPreference.ToggleRead -> onToggleRead
SwipeEndActionPreference.ToggleStarred -> onToggleStarred
val onShare: ((ArticleWithFeed) -> Unit)? = remember {
{ articleWithFeed ->
with(articleWithFeed.article) {
context.share(
arrayOf(title, link).filter { it.isNotBlank() }.joinToString(separator = "\n")
)
}
}
}
@ -215,6 +229,7 @@ fun FlowPage(
}
}
) {
var showMenu by remember { mutableStateOf(false) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
@ -287,15 +302,18 @@ fun FlowPage(
isShowFeedIcon = articleListFeedIcon.value,
isShowStickyHeader = articleListDateStickyHeader.value,
articleListTonalElevation = articleListTonalElevation.value,
isScrollInProgress = { listState.isScrollInProgress },
isSwipeEnabled = { listState.isScrollInProgress },
onClick = {
onSearch = false
navController.navigate("${RouteName.READING}/${it.article.id}") {
launchSingleTop = true
}
},
onSwipeStartToEnd = onSwipeStartToEnd,
onSwipeEndToStart = onSwipeEndToStart
onToggleStarred = onToggleStarred,
onToggleRead = onToggleRead,
onMarkAboveAsRead = onMarkAboveAsRead,
onMarkBelowAsRead = onMarkBelowAsRead,
onShare = onShare,
)
item {
Spacer(modifier = Modifier.height(128.dp))

View File

@ -2,6 +2,7 @@ package me.ash.reader.ui.page.home.flow
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel
import androidx.paging.compose.LazyPagingItems
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@ -10,10 +11,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import me.ash.reader.domain.model.article.ArticleFlowItem
import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.domain.model.general.MarkAsReadConditions
import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.IODispatcher
import java.util.Date
import java.util.function.BiPredicate
import javax.inject.Inject
@HiltViewModel
@ -70,6 +75,28 @@ class FlowViewModel @Inject constructor(
}
}
}
fun markAsReadFromListByDate(
date: Date,
isBefore: Boolean,
lazyPagingItems: LazyPagingItems<ArticleFlowItem>,
) {
applicationScope.launch(ioDispatcher) {
val articleIdSet = lazyPagingItems.itemSnapshotList.asSequence()
.filterIsInstance<ArticleFlowItem.Article>()
.map { it.articleWithFeed.article }
.filter {
if (isBefore) {
date > it.date
} else {
date < it.date
}
}
.map { it.id }
.toSet()
rssService.get().batchMarkAsRead(articleIds = articleIdSet, isUnread = false)
}
}
}
data class FlowUiState(

View File

@ -421,6 +421,8 @@
<string name="toggle_starred">Toggle starred</string>
<string name="export">Export</string>
<string name="pull_to_switch_article">Pull to switch article</string>
<string name="mark_above_as_read">Mark above as read</string>
<string name="mark_below_as_read">Mark below as read</string>
<string name="save">Save</string>
<string name="image_saved">Image saved</string>
<string name="permission_denied">Permission denied</string>

View File

@ -13,7 +13,7 @@ buildscript {
// https://developer.android.com/jetpack/androidx/releases/navigation
navigation = '2.5.0-rc01'
// https://developer.android.com/jetpack/androidx/releases/paging
paging = '3.1.1'
paging = '3.2.1'
// https://developer.android.com/jetpack/androidx/releases/room
room = '2.6.1'
// https://developer.android.com/jetpack/androidx/releases/datastore