mirror of
https://github.com/Ashinch/ReadYou.git
synced 2025-02-01 20:07:24 +01:00
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:
parent
db65c3dca5
commit
53523e44ab
@ -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"
|
||||
|
@ -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()
|
||||
|
@ -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() }
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
10
app/src/main/java/me/ash/reader/ui/motion/MaterialEasing.kt
Normal file
10
app/src/main/java/me/ash/reader/ui/motion/MaterialEasing.kt
Normal 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
|
||||
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user