Merge branch 'main' into adaptive
This commit is contained in:
commit
5862cf36ae
@ -158,7 +158,6 @@ dependencies {
|
||||
implementation(libs.readability4j)
|
||||
implementation(libs.rome)
|
||||
implementation(libs.telephoto)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.coroutines)
|
||||
implementation(libs.retrofit)
|
||||
|
@ -16,14 +16,15 @@ import me.ash.reader.ui.ext.put
|
||||
val LocalFlowArticleListReadIndicator =
|
||||
compositionLocalOf<FlowArticleReadIndicatorPreference> { FlowArticleReadIndicatorPreference.default }
|
||||
|
||||
sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference() {
|
||||
object ExcludingStarred : FlowArticleReadIndicatorPreference(true)
|
||||
object AllRead : FlowArticleReadIndicatorPreference(false)
|
||||
sealed class FlowArticleReadIndicatorPreference(val value: Int) : Preference() {
|
||||
data object ExcludingStarred : FlowArticleReadIndicatorPreference(0)
|
||||
data object AllRead : FlowArticleReadIndicatorPreference(1)
|
||||
data object None : FlowArticleReadIndicatorPreference(2)
|
||||
|
||||
override fun put(context: Context, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
context.dataStore.put(
|
||||
DataStoreKey.flowArticleListReadIndicator,
|
||||
flowArticleListReadIndicator,
|
||||
value
|
||||
)
|
||||
}
|
||||
@ -34,26 +35,22 @@ sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference
|
||||
return when (this) {
|
||||
AllRead -> stringResource(id = R.string.all_read)
|
||||
ExcludingStarred -> stringResource(id = R.string.read_excluding_starred)
|
||||
None -> stringResource(id = R.string.none)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val default = ExcludingStarred
|
||||
val values = listOf(ExcludingStarred, AllRead)
|
||||
val values = listOf(ExcludingStarred, AllRead, None)
|
||||
|
||||
fun fromPreferences(preferences: Preferences) =
|
||||
when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key<Boolean>]) {
|
||||
true -> ExcludingStarred
|
||||
false -> AllRead
|
||||
when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key<Int>]) {
|
||||
0 -> ExcludingStarred
|
||||
1 -> AllRead
|
||||
2 -> None
|
||||
else -> default
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
operator fun FlowArticleReadIndicatorPreference.not(): FlowArticleReadIndicatorPreference =
|
||||
when (value) {
|
||||
true -> FlowArticleReadIndicatorPreference.AllRead
|
||||
false -> FlowArticleReadIndicatorPreference.ExcludingStarred
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ import me.ash.reader.ui.ext.put
|
||||
val LocalReadingTextLineHeight = compositionLocalOf { ReadingTextLineHeightPreference.default }
|
||||
|
||||
data object ReadingTextLineHeightPreference {
|
||||
const val default = 1.5F
|
||||
const val default = 1.0F
|
||||
private val range = 0.8F..2F
|
||||
|
||||
fun put(context: Context, scope: CoroutineScope, value: Float) {
|
||||
|
@ -0,0 +1,58 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
internal data class SwipeActionMeta(
|
||||
val value: SwipeAction,
|
||||
val isOnRightSide: Boolean,
|
||||
)
|
||||
|
||||
internal data class ActionFinder(
|
||||
val left: List<SwipeAction>,
|
||||
val right: List<SwipeAction>
|
||||
) {
|
||||
|
||||
fun actionAt(offset: Float, totalWidth: Int): SwipeActionMeta? {
|
||||
if (offset == 0f) {
|
||||
return null
|
||||
}
|
||||
|
||||
val isOnRightSide = offset < 0f
|
||||
val actions = if (isOnRightSide) right else left
|
||||
|
||||
val actionAtOffset = actions.actionAt(
|
||||
offset = abs(offset).coerceAtMost(totalWidth.toFloat()),
|
||||
totalWidth = totalWidth
|
||||
)
|
||||
return actionAtOffset?.let {
|
||||
SwipeActionMeta(
|
||||
value = actionAtOffset,
|
||||
isOnRightSide = isOnRightSide
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SwipeAction>.actionAt(offset: Float, totalWidth: Int): SwipeAction? {
|
||||
if (isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val totalWeights = this.sumOf { it.weight }
|
||||
var offsetSoFar = 0.0
|
||||
|
||||
@Suppress("ReplaceManualRangeWithIndicesCalls") // Avoid allocating an Iterator for every pixel swiped.
|
||||
for (i in 0 until size) {
|
||||
val action = this[i]
|
||||
val actionWidth = (action.weight / totalWeights) * totalWidth
|
||||
val actionEndX = offsetSoFar + actionWidth
|
||||
|
||||
if (offset <= actionEndX) {
|
||||
return action
|
||||
}
|
||||
offsetSoFar += actionEndX
|
||||
}
|
||||
|
||||
// Precision error in the above loop maybe?
|
||||
error("Couldn't find any swipe action. Width=$totalWidth, offset=$offset, actions=$this")
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Represents an action that can be shown in [SwipeableActionsBox].
|
||||
*
|
||||
* @param background Color used as the background of [SwipeableActionsBox] while
|
||||
* this action is visible. If this action is swiped, its background color is
|
||||
* also used for drawing a ripple over the content for providing a visual
|
||||
* feedback to the user.
|
||||
*
|
||||
* @param weight The proportional width to give to this element, as related
|
||||
* to the total of all weighted siblings. [SwipeableActionsBox] will divide its
|
||||
* horizontal space and distribute it to actions according to their weight.
|
||||
*
|
||||
* @param isUndo Determines the direction in which a ripple is drawn when this
|
||||
* action is swiped. When false, the ripple grows from this action's position
|
||||
* to consume the entire composable, and vice versa. This can be used for
|
||||
* actions that can be toggled on and off.
|
||||
*/
|
||||
class SwipeAction(
|
||||
val onSwipe: () -> Unit,
|
||||
val icon: @Composable () -> Unit,
|
||||
val background: Color,
|
||||
val weight: Double = 1.0,
|
||||
val isUndo: Boolean = false
|
||||
) {
|
||||
init {
|
||||
require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }
|
||||
}
|
||||
|
||||
fun copy(
|
||||
onSwipe: () -> Unit = this.onSwipe,
|
||||
icon: @Composable () -> Unit = this.icon,
|
||||
background: Color = this.background,
|
||||
weight: Double = this.weight,
|
||||
isUndo: Boolean = this.isUndo,
|
||||
) = SwipeAction(
|
||||
onSwipe = onSwipe,
|
||||
icon = icon,
|
||||
background = background,
|
||||
weight = weight,
|
||||
isUndo = isUndo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* See [SwipeAction] for documentation.
|
||||
*/
|
||||
fun SwipeAction(
|
||||
onSwipe: () -> Unit,
|
||||
icon: Painter,
|
||||
background: Color,
|
||||
weight: Double = 1.0,
|
||||
isUndo: Boolean = false
|
||||
): SwipeAction {
|
||||
return SwipeAction(
|
||||
icon = {
|
||||
Image(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
painter = icon,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
background = background,
|
||||
weight = weight,
|
||||
onSwipe = onSwipe,
|
||||
isUndo = isUndo
|
||||
)
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
@file:Suppress("NAME_SHADOWING")
|
||||
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
internal class SwipeRippleState {
|
||||
private var ripple = mutableStateOf<SwipeRipple?>(null)
|
||||
|
||||
suspend fun animate(
|
||||
action: SwipeActionMeta,
|
||||
) {
|
||||
val drawOnRightSide = action.isOnRightSide
|
||||
val action = action.value
|
||||
|
||||
ripple.value = SwipeRipple(
|
||||
isUndo = action.isUndo,
|
||||
rightSide = drawOnRightSide,
|
||||
color = action.background,
|
||||
alpha = 0f,
|
||||
progress = 0f
|
||||
)
|
||||
|
||||
// Reverse animation feels faster (especially for larger swipe distances) so slow it down further.
|
||||
val animationDurationMs = (animationDurationMs * (if (action.isUndo) 1.75f else 1f)).roundToInt()
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
Animatable(initialValue = 0f).animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = animationDurationMs),
|
||||
block = {
|
||||
ripple.value = ripple.value!!.copy(progress = value)
|
||||
}
|
||||
)
|
||||
}
|
||||
launch {
|
||||
Animatable(initialValue = if (action.isUndo) 0f else 0.25f).animateTo(
|
||||
targetValue = if (action.isUndo) 0.5f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (action.isUndo) 0 else animationDurationMs / 2
|
||||
),
|
||||
block = {
|
||||
ripple.value = ripple.value!!.copy(alpha = value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: DrawScope) {
|
||||
ripple.value?.run {
|
||||
scope.clipRect {
|
||||
val size = scope.size
|
||||
// Start the ripple with a radius equal to the available height so that it covers the entire edge.
|
||||
val startRadius = if (isUndo) size.width + size.height else size.height
|
||||
val endRadius = if (!isUndo) size.width + size.height else size.height
|
||||
val radius = lerp(startRadius, endRadius, fraction = progress)
|
||||
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = radius,
|
||||
alpha = alpha,
|
||||
center = this.center.copy(x = if (rightSide) this.size.width + this.size.height else 0f - this.size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SwipeRipple(
|
||||
val isUndo: Boolean,
|
||||
val rightSide: Boolean,
|
||||
val color: Color,
|
||||
val alpha: Float,
|
||||
val progress: Float,
|
||||
)
|
||||
|
||||
private fun lerp(start: Float, stop: Float, fraction: Float) =
|
||||
(start * (1 - fraction) + stop * fraction)
|
@ -0,0 +1,144 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A composable that can be swiped left or right for revealing actions.
|
||||
*
|
||||
* @param swipeThreshold Minimum drag distance before any [SwipeAction] is
|
||||
* activated and can be swiped.
|
||||
*
|
||||
* @param backgroundUntilSwipeThreshold Color drawn behind the content until
|
||||
* [swipeThreshold] is reached. When the threshold is passed, this color is
|
||||
* replaced by the currently visible [SwipeAction]'s background.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeableActionsBox(
|
||||
modifier: Modifier = Modifier,
|
||||
state: SwipeableActionsState = rememberSwipeableActionsState(),
|
||||
startActions: List<SwipeAction> = emptyList(),
|
||||
endActions: List<SwipeAction> = emptyList(),
|
||||
swipeThreshold: Dp = 40.dp,
|
||||
backgroundUntilSwipeThreshold: Color = Color.DarkGray,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) = Box(modifier) {
|
||||
state.also {
|
||||
it.swipeThresholdPx = LocalDensity.current.run { swipeThreshold.toPx() }
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
it.actions = remember(endActions, startActions, isRtl) {
|
||||
ActionFinder(
|
||||
left = if (isRtl) endActions else startActions,
|
||||
right = if (isRtl) startActions else endActions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundColor = when {
|
||||
state.swipedAction != null -> state.swipedAction!!.value.background
|
||||
!state.hasCrossedSwipeThreshold() -> backgroundUntilSwipeThreshold
|
||||
state.visibleAction != null -> state.visibleAction!!.value.background
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val animatedBackgroundColor: Color = if (state.layoutWidth == 0) {
|
||||
// Use the current color immediately because paparazzi can only capture the 1st frame.
|
||||
// https://github.com/cashapp/paparazzi/issues/1261
|
||||
backgroundColor
|
||||
} else {
|
||||
animateColorAsState(backgroundColor).value
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onSizeChanged { state.layoutWidth = it.width }
|
||||
.absoluteOffset { IntOffset(x = state.offset.value.roundToInt(), y = 0) }
|
||||
.drawOverContent { state.ripple.draw(scope = this) }
|
||||
.horizontalDraggable(
|
||||
enabled = !state.isResettingOnRelease,
|
||||
onDragStopped = {
|
||||
scope.launch {
|
||||
state.handleOnDragStopped()
|
||||
}
|
||||
},
|
||||
state = state.draggableState,
|
||||
),
|
||||
content = content
|
||||
)
|
||||
|
||||
(state.swipedAction ?: state.visibleAction)?.let { action ->
|
||||
ActionIconBox(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
action = action,
|
||||
offset = state.offset.value,
|
||||
backgroundColor = animatedBackgroundColor,
|
||||
content = { action.value.icon() }
|
||||
)
|
||||
}
|
||||
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
if (state.hasCrossedSwipeThreshold() && state.swipedAction == null) {
|
||||
LaunchedEffect(state.visibleAction) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionIconBox(
|
||||
action: SwipeActionMeta,
|
||||
offset: Float,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(width = placeable.width, height = placeable.height) {
|
||||
// Align icon with the left/right edge of the content being swiped.
|
||||
val iconOffset = if (action.isOnRightSide) constraints.maxWidth + offset else offset - placeable.width
|
||||
placeable.place(x = iconOffset.roundToInt(), y = 0)
|
||||
}
|
||||
}
|
||||
.background(color = backgroundColor),
|
||||
horizontalArrangement = if (action.isOnRightSide) Arrangement.Absolute.Left else Arrangement.Absolute.Right,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.drawOverContent(onDraw: DrawScope.() -> Unit): Modifier {
|
||||
return drawWithContent {
|
||||
drawContent()
|
||||
onDraw(this)
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun rememberSwipeableActionsState(): SwipeableActionsState {
|
||||
return remember { SwipeableActionsState() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a [SwipeableActionsBox].
|
||||
*/
|
||||
@Stable
|
||||
class SwipeableActionsState internal constructor() {
|
||||
/**
|
||||
* The current position (in pixels) of a [SwipeableActionsBox].
|
||||
*/
|
||||
val offset: State<Float> get() = offsetState
|
||||
internal var offsetState = mutableStateOf(0f)
|
||||
|
||||
/**
|
||||
* Whether [SwipeableActionsBox] is currently animating to reset its offset after it was swiped.
|
||||
*/
|
||||
val isResettingOnRelease: Boolean by derivedStateOf {
|
||||
swipedAction != null
|
||||
}
|
||||
|
||||
internal var layoutWidth: Int by mutableIntStateOf(0)
|
||||
internal var swipeThresholdPx: Float by mutableFloatStateOf(0f)
|
||||
internal val ripple = SwipeRippleState()
|
||||
|
||||
internal var actions: ActionFinder by mutableStateOf(
|
||||
ActionFinder(left = emptyList(), right = emptyList())
|
||||
)
|
||||
internal val visibleAction: SwipeActionMeta? by derivedStateOf {
|
||||
actions.actionAt(offsetState.value, totalWidth = layoutWidth)
|
||||
}
|
||||
internal var swipedAction: SwipeActionMeta? by mutableStateOf(null)
|
||||
|
||||
internal val draggableState = DraggableState { delta ->
|
||||
val targetOffset = offsetState.value + delta
|
||||
|
||||
val canSwipeTowardsRight = actions.left.isNotEmpty()
|
||||
val canSwipeTowardsLeft = actions.right.isNotEmpty()
|
||||
|
||||
val isAllowed = isResettingOnRelease
|
||||
|| targetOffset == 0f
|
||||
|| (targetOffset > 0f && canSwipeTowardsRight)
|
||||
|| (targetOffset < 0f && canSwipeTowardsLeft)
|
||||
offsetState.value += if (isAllowed) delta else delta / 10
|
||||
}
|
||||
|
||||
internal fun hasCrossedSwipeThreshold(): Boolean {
|
||||
return abs(offsetState.value) > swipeThresholdPx
|
||||
}
|
||||
|
||||
internal suspend fun handleOnDragStopped() = coroutineScope {
|
||||
launch {
|
||||
if (hasCrossedSwipeThreshold()) {
|
||||
visibleAction?.let { action ->
|
||||
swipedAction = action
|
||||
action.value.onSwipe()
|
||||
ripple.animate(action = action)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
draggableState.drag(MutatePriority.PreventUserInput) {
|
||||
Animatable(offsetState.value).animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = animationDurationMs),
|
||||
) {
|
||||
dragBy(value - offsetState.value)
|
||||
}
|
||||
}
|
||||
swipedAction = null
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
internal const val animationDurationMs = 4_00
|
@ -0,0 +1,282 @@
|
||||
package me.ash.reader.ui.component.swipe
|
||||
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.PointerInputModifierNode
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.ui.component.swipe.DragEvent.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* Workaround for [269627294](https://issuetracker.google.com/issues/269627294).
|
||||
*
|
||||
* Copy of Compose UI's draggable modifier, but with an additional check to only accept horizontal swipes
|
||||
* made within 22.5°. This prevents accidental swipes while scrolling a vertical list.
|
||||
*/
|
||||
internal fun Modifier.horizontalDraggable(
|
||||
state: DraggableState,
|
||||
enabled: Boolean = true,
|
||||
startDragImmediately: Boolean = false,
|
||||
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
|
||||
onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
|
||||
): Modifier = this then DraggableElement(
|
||||
state = state,
|
||||
enabled = enabled,
|
||||
startDragImmediately = { startDragImmediately },
|
||||
onDragStarted = onDragStarted,
|
||||
onDragStopped = { velocity -> onDragStopped(velocity.x) },
|
||||
)
|
||||
|
||||
internal data class DraggableElement(
|
||||
private val state: DraggableState,
|
||||
private val enabled: Boolean,
|
||||
private val startDragImmediately: () -> Boolean,
|
||||
private val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit,
|
||||
private val onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
|
||||
) : ModifierNodeElement<DraggableNode>() {
|
||||
|
||||
override fun create(): DraggableNode = DraggableNode(
|
||||
state,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
onDragStarted,
|
||||
onDragStopped,
|
||||
)
|
||||
|
||||
override fun update(node: DraggableNode) {
|
||||
node.update(
|
||||
state = state,
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
onDragStarted = onDragStarted,
|
||||
onDragStopped = onDragStopped,
|
||||
)
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "draggable"
|
||||
properties["enabled"] = enabled
|
||||
properties["startDragImmediately"] = startDragImmediately
|
||||
properties["onDragStarted"] = onDragStarted
|
||||
properties["onDragStopped"] = onDragStopped
|
||||
properties["state"] = state
|
||||
}
|
||||
}
|
||||
|
||||
internal class DraggableNode(
|
||||
private var state: DraggableState,
|
||||
private var enabled: Boolean,
|
||||
private var startDragImmediately: () -> Boolean,
|
||||
private var onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit,
|
||||
private var onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
|
||||
) : DelegatingNode(), PointerInputModifierNode {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
private val channel = Channel<DragEvent>(capacity = Channel.UNLIMITED)
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
private val pointerInputNode = SuspendingPointerInputModifierNode {
|
||||
if (!enabled) {
|
||||
return@SuspendingPointerInputModifierNode
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
while (isActive) {
|
||||
var event = channel.receive()
|
||||
if (event !is DragStarted) continue
|
||||
onDragStarted.invoke(this, event.startPoint)
|
||||
try {
|
||||
state.drag(MutatePriority.UserInput) {
|
||||
while (event !is DragStopped && event !is DragCancelled) {
|
||||
(event as? DragDelta)?.let { dragBy(it.delta.x) }
|
||||
event = channel.receive()
|
||||
}
|
||||
}
|
||||
event.let { event ->
|
||||
if (event is DragStopped) {
|
||||
onDragStopped.invoke(this, event.velocity)
|
||||
} else if (event is DragCancelled) {
|
||||
onDragStopped.invoke(this, Velocity.Zero)
|
||||
}
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
onDragStopped.invoke(this, Velocity.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitEachGesture {
|
||||
val awaited = awaitDownAndSlop(
|
||||
startDragImmediately = startDragImmediately,
|
||||
velocityTracker = velocityTracker,
|
||||
)
|
||||
|
||||
if (awaited != null) {
|
||||
var isDragSuccessful = false
|
||||
try {
|
||||
isDragSuccessful = awaitDrag(
|
||||
startEvent = awaited.first,
|
||||
initialDelta = awaited.second,
|
||||
velocityTracker = velocityTracker,
|
||||
channel = channel,
|
||||
reverseDirection = false,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
isDragSuccessful = false
|
||||
if (!isActive) throw cancellation
|
||||
} finally {
|
||||
val event = if (isDragSuccessful) {
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
DragStopped(velocity)
|
||||
} else {
|
||||
DragCancelled
|
||||
}
|
||||
channel.trySend(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
delegate(pointerInputNode)
|
||||
}
|
||||
|
||||
override fun onPointerEvent(
|
||||
pointerEvent: PointerEvent,
|
||||
pass: PointerEventPass,
|
||||
bounds: IntSize
|
||||
) {
|
||||
pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
|
||||
}
|
||||
|
||||
override fun onCancelPointerInput() {
|
||||
pointerInputNode.onCancelPointerInput()
|
||||
}
|
||||
|
||||
fun update(
|
||||
state: DraggableState,
|
||||
enabled: Boolean,
|
||||
startDragImmediately: () -> Boolean,
|
||||
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit,
|
||||
onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
|
||||
) {
|
||||
var resetPointerInputHandling = false
|
||||
if (this.state != state) {
|
||||
this.state = state
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
if (this.enabled != enabled) {
|
||||
this.enabled = enabled
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
this.startDragImmediately = startDragImmediately
|
||||
this.onDragStarted = onDragStarted
|
||||
this.onDragStopped = onDragStopped
|
||||
if (resetPointerInputHandling) {
|
||||
pointerInputNode.resetPointerInputHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AwaitPointerEventScope.awaitDownAndSlop(
|
||||
startDragImmediately: () -> Boolean,
|
||||
velocityTracker: VelocityTracker,
|
||||
): Pair<PointerInputChange, Offset>? {
|
||||
val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
return if (startDragImmediately()) {
|
||||
initialDown.consume()
|
||||
velocityTracker.addPointerInputChange(initialDown)
|
||||
// since we start immediately we don't wait for slop and the initial delta is 0
|
||||
initialDown to Offset.Zero
|
||||
} else {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
velocityTracker.addPointerInputChange(down)
|
||||
var initialDelta = Offset.Zero
|
||||
val postPointerSlop = { event: PointerInputChange, overSlop: Float ->
|
||||
val isHorizontalSwipe = event.positionChange().let {
|
||||
abs(it.x) > abs(it.y * 2f) // Accept swipes made at a max. of 22.5° in either direction.
|
||||
}
|
||||
if (isHorizontalSwipe) {
|
||||
velocityTracker.addPointerInputChange(event)
|
||||
event.consume()
|
||||
initialDelta = Offset(x = overSlop, 0f)
|
||||
} else {
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
|
||||
val afterSlopResult = awaitHorizontalTouchSlopOrCancellation(
|
||||
pointerId = down.id,
|
||||
onTouchSlopReached = postPointerSlop
|
||||
)
|
||||
|
||||
if (afterSlopResult != null) afterSlopResult to initialDelta else null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AwaitPointerEventScope.awaitDrag(
|
||||
startEvent: PointerInputChange,
|
||||
initialDelta: Offset,
|
||||
velocityTracker: VelocityTracker,
|
||||
channel: SendChannel<DragEvent>,
|
||||
reverseDirection: Boolean,
|
||||
): Boolean {
|
||||
val overSlopOffset = initialDelta
|
||||
val xSign = sign(startEvent.position.x)
|
||||
val ySign = sign(startEvent.position.y)
|
||||
val adjustedStart = startEvent.position -
|
||||
Offset(overSlopOffset.x * xSign, overSlopOffset.y * ySign)
|
||||
channel.trySend(DragStarted(adjustedStart))
|
||||
|
||||
channel.trySend(DragDelta(if (reverseDirection) initialDelta * -1f else initialDelta))
|
||||
|
||||
return drag(pointerId = startEvent.id) { event ->
|
||||
// Velocity tracker takes all events, even UP
|
||||
velocityTracker.addPointerInputChange(event)
|
||||
|
||||
// Dispatch only MOVE events
|
||||
if (!event.changedToUpIgnoreConsumed()) {
|
||||
val delta = event.positionChange()
|
||||
event.consume()
|
||||
channel.trySend(DragDelta(if (reverseDirection) delta * -1f else delta))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DragEvent {
|
||||
class DragStarted(val startPoint: Offset) : DragEvent()
|
||||
class DragStopped(val velocity: Velocity) : DragEvent()
|
||||
data object DragCancelled : DragEvent()
|
||||
class DragDelta(val delta: Offset) : DragEvent()
|
||||
}
|
@ -27,7 +27,7 @@ object WebViewStyle {
|
||||
:root {
|
||||
/* --font-family: Inter; */
|
||||
--font-size: ${fontSize}px;
|
||||
--line-height: ${lineHeight};
|
||||
--line-height: ${lineHeight * 1.5f};
|
||||
--letter-spacing: ${letterSpacing}px;
|
||||
--text-margin: ${textMargin}px;
|
||||
--text-color: ${argbToCssColor(textColor)};
|
||||
|
@ -131,7 +131,7 @@ data class DataStoreKey<T>(
|
||||
const val flowArticleListTime = "flowArticleListTime"
|
||||
const val flowArticleListDateStickyHeader = "flowArticleListDateStickyHeader"
|
||||
const val flowArticleListTonalElevation = "flowArticleListTonalElevation"
|
||||
const val flowArticleListReadIndicator = "flowArticleListReadIndicator"
|
||||
const val flowArticleListReadIndicator = "flowArticleListReadStatusIndicator"
|
||||
|
||||
// Reading page
|
||||
const val readingRenderer = "readingRender"
|
||||
@ -207,7 +207,7 @@ data class DataStoreKey<T>(
|
||||
flowArticleListTime to DataStoreKey(booleanPreferencesKey(flowArticleListTime), Boolean::class.java),
|
||||
flowArticleListDateStickyHeader to DataStoreKey(booleanPreferencesKey(flowArticleListDateStickyHeader), Boolean::class.java),
|
||||
flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::class.java),
|
||||
flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java),
|
||||
flowArticleListReadIndicator to DataStoreKey(intPreferencesKey(flowArticleListReadIndicator), Int::class.java),
|
||||
// Reading page
|
||||
readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java),
|
||||
readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java),
|
||||
|
@ -1,14 +1,6 @@
|
||||
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.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.exponentialDecay
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@ -90,8 +82,8 @@ import me.ash.reader.ui.page.settings.color.flow.generateArticleWithFeedPreview
|
||||
import me.ash.reader.ui.theme.Shape20
|
||||
import me.ash.reader.ui.theme.applyTextDirection
|
||||
import me.ash.reader.ui.theme.palette.onDark
|
||||
import me.saket.swipe.SwipeAction
|
||||
import me.saket.swipe.SwipeableActionsBox
|
||||
import me.ash.reader.ui.component.swipe.SwipeAction
|
||||
import me.ash.reader.ui.component.swipe.SwipeableActionsBox
|
||||
|
||||
private const val TAG = "ArticleItem"
|
||||
|
||||
@ -100,6 +92,7 @@ fun ArticleItem(
|
||||
modifier: Modifier = Modifier,
|
||||
articleWithFeed: ArticleWithFeed,
|
||||
isHighlighted: Boolean = false,
|
||||
isUnread: Boolean = articleWithFeed.article.isUnread,
|
||||
onClick: (ArticleWithFeed) -> Unit = {},
|
||||
onLongClick: (() -> Unit)? = null
|
||||
) {
|
||||
@ -115,8 +108,8 @@ fun ArticleItem(
|
||||
dateString = article.dateString,
|
||||
imgData = article.img,
|
||||
isStarred = article.isStarred,
|
||||
isUnread = article.isUnread,
|
||||
isHighlighted = isHighlighted,
|
||||
isUnread = isUnread,
|
||||
onClick = { onClick(articleWithFeed) },
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
@ -159,6 +152,9 @@ fun ArticleItem(
|
||||
.alpha(
|
||||
if (isHighlighted) 1f else {
|
||||
when (articleListReadIndicator) {
|
||||
|
||||
FlowArticleReadIndicatorPreference.None -> 1f
|
||||
|
||||
FlowArticleReadIndicatorPreference.AllRead -> {
|
||||
if (isUnread) 1f else 0.5f
|
||||
}
|
||||
@ -294,11 +290,12 @@ private const val SwipeActionDelay = 300L
|
||||
fun SwipeableArticleItem(
|
||||
articleWithFeed: ArticleWithFeed,
|
||||
isHighlighted: Boolean = false,
|
||||
isUnread: Boolean = articleWithFeed.article.isUnread,
|
||||
articleListTonalElevation: Int = 0,
|
||||
onClick: (ArticleWithFeed) -> Unit = {},
|
||||
isMenuEnabled: Boolean = true,
|
||||
onToggleStarred: (ArticleWithFeed) -> Unit = { },
|
||||
onToggleRead: (ArticleWithFeed) -> Unit = { },
|
||||
onToggleStarred: (ArticleWithFeed) -> Unit = {},
|
||||
onToggleRead: (ArticleWithFeed) -> Unit = {},
|
||||
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
|
||||
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
|
||||
onShare: ((ArticleWithFeed) -> Unit)? = null,
|
||||
@ -323,7 +320,7 @@ fun SwipeableArticleItem(
|
||||
|
||||
SwipeActionBox(
|
||||
articleWithFeed = articleWithFeed,
|
||||
isRead = !articleWithFeed.article.isUnread,
|
||||
isRead = !isUnread,
|
||||
isStarred = articleWithFeed.article.isStarred,
|
||||
onToggleStarred = onToggleStarred,
|
||||
onToggleRead = onToggleRead
|
||||
@ -350,6 +347,7 @@ fun SwipeableArticleItem(
|
||||
ArticleItem(
|
||||
articleWithFeed = articleWithFeed,
|
||||
isHighlighted = isHighlighted,
|
||||
isUnread = isUnread,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
|
@ -16,6 +16,7 @@ import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun LazyListScope.ArticleList(
|
||||
pagingItems: LazyPagingItems<ArticleFlowItem>,
|
||||
diffMap: Map<String, Diff>,
|
||||
isShowFeedIcon: Boolean,
|
||||
isShowStickyHeader: Boolean,
|
||||
articleListTonalElevation: Int,
|
||||
@ -39,9 +40,11 @@ fun LazyListScope.ArticleList(
|
||||
) { index ->
|
||||
when (val item = pagingItems[index]) {
|
||||
is ArticleFlowItem.Article -> {
|
||||
val article = item.articleWithFeed.article
|
||||
SwipeableArticleItem(
|
||||
articleWithFeed = item.articleWithFeed,
|
||||
isHighlighted = readingArticleId == item.articleWithFeed.article.id,
|
||||
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
|
||||
articleListTonalElevation = articleListTonalElevation,
|
||||
onClick = onClick,
|
||||
isMenuEnabled = isMenuEnabled,
|
||||
@ -68,9 +71,11 @@ fun LazyListScope.ArticleList(
|
||||
when (val item = pagingItems.peek(index)) {
|
||||
is ArticleFlowItem.Article -> {
|
||||
item(key = key(item), contentType = contentType(item)) {
|
||||
val article = item.articleWithFeed.article
|
||||
SwipeableArticleItem(
|
||||
articleWithFeed = item.articleWithFeed,
|
||||
isHighlighted = readingArticleId == item.articleWithFeed.article.id,
|
||||
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
|
||||
articleListTonalElevation = articleListTonalElevation,
|
||||
onClick = onClick,
|
||||
isMenuEnabled = isMenuEnabled,
|
||||
|
@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@ -44,7 +45,6 @@ import me.ash.reader.R
|
||||
import me.ash.reader.domain.model.article.ArticleFlowItem
|
||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||
import me.ash.reader.domain.model.general.Filter
|
||||
import me.ash.reader.domain.model.general.MarkAsReadConditions
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListDateStickyHeader
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListTonalElevation
|
||||
@ -110,6 +110,12 @@ fun FlowPage(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(pagingItems) {
|
||||
onDispose {
|
||||
flowViewModel.commitDiff()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(owner) {
|
||||
homeViewModel.syncWorkLiveData.observe(owner) { workInfoList ->
|
||||
workInfoList.let {
|
||||
@ -130,15 +136,16 @@ fun FlowPage(
|
||||
|
||||
val onToggleRead: (ArticleWithFeed) -> Unit = remember {
|
||||
{ article ->
|
||||
flowViewModel.updateReadStatus(
|
||||
groupId = null,
|
||||
feedId = null,
|
||||
articleId = article.article.id,
|
||||
conditions = MarkAsReadConditions.All,
|
||||
isUnread = !article.article.isUnread,
|
||||
)
|
||||
val id = article.article.id
|
||||
val isUnread = article.article.isUnread
|
||||
|
||||
with(flowViewModel.diffMap) {
|
||||
if (contains(id)) remove(id)
|
||||
else put(id, Diff(isUnread = !isUnread))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = remember {
|
||||
{
|
||||
flowViewModel.markAsReadFromListByDate(
|
||||
@ -338,6 +345,7 @@ fun FlowPage(
|
||||
ArticleList(
|
||||
pagingItems = pagingItems,
|
||||
readingArticleId = readingArticleId,
|
||||
diffMap = flowViewModel.diffMap,
|
||||
isShowFeedIcon = articleListFeedIcon.value,
|
||||
isShowStickyHeader = articleListDateStickyHeader.value,
|
||||
articleListTonalElevation = articleListTonalElevation.value,
|
||||
|
@ -1,24 +1,22 @@
|
||||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
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
|
||||
@ -32,6 +30,7 @@ class FlowViewModel @Inject constructor(
|
||||
|
||||
private val _flowUiState = MutableStateFlow(FlowUiState())
|
||||
val flowUiState: StateFlow<FlowUiState> = _flowUiState.asStateFlow()
|
||||
val diffMap = mutableStateMapOf<String, Diff>()
|
||||
|
||||
fun sync() {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
@ -45,10 +44,8 @@ class FlowViewModel @Inject constructor(
|
||||
articleId: String?,
|
||||
conditions: MarkAsReadConditions,
|
||||
isUnread: Boolean,
|
||||
withDelay: Long = 0,
|
||||
) {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
delay(withDelay)
|
||||
rssService.get().markAsRead(
|
||||
groupId = groupId,
|
||||
feedId = feedId,
|
||||
@ -62,11 +59,8 @@ class FlowViewModel @Inject constructor(
|
||||
fun updateStarredStatus(
|
||||
articleId: String?,
|
||||
isStarred: Boolean,
|
||||
withDelay: Long = 0,
|
||||
) {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
// FIXME: a dirty hack to ensure the swipe animation doesn't get interrupted when recomposed, remove this after implementing a lazy tag!
|
||||
delay(withDelay)
|
||||
if (articleId != null) {
|
||||
rssService.get().markAsStarred(
|
||||
articleId = articleId,
|
||||
@ -97,6 +91,21 @@ class FlowViewModel @Inject constructor(
|
||||
rssService.get().batchMarkAsRead(articleIds = articleIdSet, isUnread = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitDiff() {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
val markAsReadArticles =
|
||||
diffMap.filter { !it.value.isUnread }.map { it.key }.toSet()
|
||||
val markAsUnreadArticles =
|
||||
diffMap.filter { it.value.isUnread }.map { it.key }.toSet()
|
||||
|
||||
rssService.get()
|
||||
.batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false)
|
||||
rssService.get()
|
||||
.batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true)
|
||||
|
||||
}.invokeOnCompletion { diffMap.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
data class FlowUiState(
|
||||
@ -105,3 +114,5 @@ data class FlowUiState(
|
||||
val isBack: Boolean = false,
|
||||
val syncWorkInfo: String = "",
|
||||
)
|
||||
|
||||
data class Diff(val isUnread: Boolean)
|
||||
|
@ -1,6 +1,13 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -18,6 +25,7 @@ import androidx.compose.material.icons.outlined.Headphones
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarOutline
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -25,6 +33,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.ash.reader.R
|
||||
@ -59,113 +68,120 @@ fun BottomBar(
|
||||
.zIndex(1f),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
RYExtensibleVisibility(visible = isShow) {
|
||||
AnimatedVisibility(
|
||||
visible = isShow,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
Surface(
|
||||
tonalElevation = tonalElevation.value.dp,
|
||||
) {
|
||||
// TODO: Component styles await refactoring
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
disabled = false,
|
||||
imageVector = if (isUnread) {
|
||||
Icons.Filled.FiberManualRecord
|
||||
} else {
|
||||
Icons.Outlined.FiberManualRecord
|
||||
},
|
||||
contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread),
|
||||
tint = if (isUnread) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
Column {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
thickness = 0.5f.dp
|
||||
)
|
||||
Surface() {
|
||||
// TODO: Component styles await refactoring
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onUnread(!isUnread)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
disabled = false,
|
||||
imageVector = if (isStarred) {
|
||||
Icons.Rounded.Star
|
||||
} else {
|
||||
Icons.Rounded.StarOutline
|
||||
},
|
||||
contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred),
|
||||
tint = if (isStarred) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onStarred(!isStarred)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = !isNextArticleAvailable,
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = Icons.Rounded.ExpandMore,
|
||||
contentDescription = "Next Article",
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onNextArticle()
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(36.dp),
|
||||
disabled = false,
|
||||
imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones,
|
||||
contentDescription = if (renderer == ReadingRendererPreference.WebView) {
|
||||
stringResource(R.string.bionic_reading)
|
||||
} else {
|
||||
stringResource(R.string.read_aloud)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
icon = {
|
||||
BionicReadingIcon(
|
||||
filled = isBionicReading,
|
||||
size = 24.dp,
|
||||
tint = if (renderer == ReadingRendererPreference.WebView) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
if (renderer == ReadingRendererPreference.WebView) {
|
||||
onBionicReading()
|
||||
} else {
|
||||
onReadAloud()
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
disabled = false,
|
||||
imageVector = if (isUnread) {
|
||||
Icons.Filled.FiberManualRecord
|
||||
} else {
|
||||
Icons.Outlined.FiberManualRecord
|
||||
},
|
||||
contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread),
|
||||
tint = if (isUnread) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onUnread(!isUnread)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
disabled = false,
|
||||
imageVector = if (isStarred) {
|
||||
Icons.Rounded.Star
|
||||
} else {
|
||||
Icons.Rounded.StarOutline
|
||||
},
|
||||
contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred),
|
||||
tint = if (isStarred) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onStarred(!isStarred)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = !isNextArticleAvailable,
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = Icons.Rounded.ExpandMore,
|
||||
contentDescription = "Next Article",
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onNextArticle()
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
modifier = Modifier.size(36.dp),
|
||||
disabled = false,
|
||||
imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones,
|
||||
contentDescription = if (renderer == ReadingRendererPreference.WebView) {
|
||||
stringResource(R.string.bionic_reading)
|
||||
} else {
|
||||
stringResource(R.string.read_aloud)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
icon = {
|
||||
BionicReadingIcon(
|
||||
filled = isBionicReading,
|
||||
size = 24.dp,
|
||||
tint = if (renderer == ReadingRendererPreference.WebView) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
if (renderer == ReadingRendererPreference.WebView) {
|
||||
onBionicReading()
|
||||
} else {
|
||||
onReadAloud()
|
||||
}
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = false,
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = if (isFullContent) {
|
||||
Icons.AutoMirrored.Rounded.Article
|
||||
} else {
|
||||
Icons.AutoMirrored.Outlined.Article
|
||||
},
|
||||
contentDescription = stringResource(R.string.parse_full_content),
|
||||
tint = if (isFullContent) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onFullContent(!isFullContent)
|
||||
}
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = false,
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = if (isFullContent) {
|
||||
Icons.AutoMirrored.Rounded.Article
|
||||
} else {
|
||||
Icons.AutoMirrored.Outlined.Article
|
||||
},
|
||||
contentDescription = stringResource(R.string.parse_full_content),
|
||||
tint = if (isFullContent) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
onFullContent(!isFullContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -47,6 +48,7 @@ fun Content(
|
||||
author: String? = null,
|
||||
link: String? = null,
|
||||
publishedDate: Date,
|
||||
scrollState: ScrollState,
|
||||
listState: LazyListState,
|
||||
isLoading: Boolean,
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
@ -68,21 +70,21 @@ fun Content(
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
when (renderer) {
|
||||
ReadingRendererPreference.WebView -> {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = contentPadding.calculateTopPadding())
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scrollState)
|
||||
|
||||
) {
|
||||
// Top bar height
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
// padding
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
) {
|
||||
DisableSelection {
|
||||
Metadata(
|
||||
|
@ -5,10 +5,9 @@ import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.LocalOverscrollConfiguration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -19,12 +18,15 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
@ -33,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
|
||||
@ -58,6 +61,7 @@ fun ReadingPage(
|
||||
readingViewModel: ReadingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
|
||||
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
|
||||
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
|
||||
@ -75,6 +79,12 @@ fun ReadingPage(
|
||||
true
|
||||
}
|
||||
|
||||
var showTopDivider by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var bringToTop by remember { mutableStateOf(false) }
|
||||
|
||||
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@ -99,23 +109,23 @@ fun ReadingPage(
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
// topBarTonalElevation = tonalElevation.value.dp,
|
||||
// containerTonalElevation = tonalElevation.value.dp,
|
||||
content = { paddings ->
|
||||
Log.i("RLog", "TopBar: recomposition")
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Top Bar
|
||||
TopBar(
|
||||
navController = navController,
|
||||
isShow = isShowToolBar,
|
||||
windowInsets = WindowInsets(top = paddings.calculateTopPadding()),
|
||||
title = readerState.title,
|
||||
link = readerState.link,
|
||||
onClose = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
if (readerState.articleId != null) {
|
||||
TopBar(
|
||||
navController = navController,
|
||||
isShow = isShowToolBar,
|
||||
showDivider = showTopDivider,
|
||||
title = readerState.title,
|
||||
link = readerState.link,
|
||||
onClick = { bringToTop = true },
|
||||
onClose = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty()
|
||||
val isPreviousArticleAvailable = !readerState.previousArticleId.isNullOrEmpty()
|
||||
@ -159,12 +169,31 @@ fun ReadingPage(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
val listState = rememberSaveable(
|
||||
inputs = arrayOf(content),
|
||||
saver = LazyListState.Saver
|
||||
) { LazyListState() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(bringToTop) {
|
||||
if (bringToTop) {
|
||||
scope.launch {
|
||||
if (scrollState.value != 0) {
|
||||
scrollState.animateScrollTo(0)
|
||||
} else if (listState.firstVisibleItemIndex != 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}.invokeOnCompletion { bringToTop = false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
showTopDivider = snapshotFlow {
|
||||
scrollState.value != 0 || listState.firstVisibleItemIndex != 0
|
||||
}.collectAsStateValue(initial = false)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalOverscrollConfiguration provides
|
||||
@ -197,6 +226,7 @@ fun ReadingPage(
|
||||
link = link,
|
||||
publishedDate = publishedDate,
|
||||
isLoading = content is ReaderState.Loading,
|
||||
scrollState = scrollState,
|
||||
listState = listState,
|
||||
onImageClick = { imgUrl, altText ->
|
||||
currentImageData = ImageData(imgUrl, altText)
|
||||
|
@ -1,22 +1,38 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Palette
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavHostController
|
||||
@ -24,7 +40,6 @@ import me.ash.reader.R
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
|
||||
import me.ash.reader.infrastructure.preference.LocalSharedContent
|
||||
import me.ash.reader.ui.component.base.FeedbackIconButton
|
||||
import me.ash.reader.ui.component.base.RYExtensibleVisibility
|
||||
import me.ash.reader.ui.ext.surfaceColorAtElevation
|
||||
import me.ash.reader.ui.page.common.RouteName
|
||||
|
||||
@ -33,9 +48,10 @@ import me.ash.reader.ui.page.common.RouteName
|
||||
fun TopBar(
|
||||
navController: NavHostController,
|
||||
isShow: Boolean,
|
||||
windowInsets: WindowInsets = WindowInsets(0.dp),
|
||||
showDivider: Boolean = false,
|
||||
title: String? = "",
|
||||
link: String? = "",
|
||||
onClick: (() -> Unit)? = null,
|
||||
onClose: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@ -48,43 +64,70 @@ fun TopBar(
|
||||
.zIndex(1f),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
RYExtensibleVisibility(visible = isShow) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
modifier = Modifier,
|
||||
windowInsets = windowInsets,
|
||||
navigationIcon = {
|
||||
FeedbackIconButton(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = stringResource(R.string.close),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
FeedbackIconButton(
|
||||
modifier = Modifier.size(22.dp),
|
||||
imageVector = Icons.Outlined.Palette,
|
||||
contentDescription = stringResource(R.string.style),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
navController.navigate(RouteName.READING_PAGE_STYLE) {
|
||||
launchSingleTop = true
|
||||
Column(modifier = if (onClick == null) Modifier else Modifier.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(
|
||||
WindowInsets.statusBars
|
||||
.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
) {}
|
||||
AnimatedVisibility(
|
||||
visible = isShow,
|
||||
enter = expandVertically(expandFrom = Alignment.Top),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top)
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
modifier = Modifier,
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
navigationIcon = {
|
||||
FeedbackIconButton(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = stringResource(R.string.close),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
FeedbackIconButton(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Outlined.Share,
|
||||
contentDescription = stringResource(R.string.share),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
sharedContent.share(context, title, link)
|
||||
}
|
||||
}, colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(tonalElevation.value.dp),
|
||||
}, actions = {
|
||||
FeedbackIconButton(
|
||||
modifier = Modifier.size(22.dp),
|
||||
imageVector = Icons.Outlined.Palette,
|
||||
contentDescription = stringResource(R.string.style),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
navController.navigate(RouteName.READING_PAGE_STYLE) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
FeedbackIconButton(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Outlined.Share,
|
||||
contentDescription = stringResource(R.string.share),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
sharedContent.share(context, title, link)
|
||||
}
|
||||
}, colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
tonalElevation.value.dp
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showDivider) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
thickness = 0.5f.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user