feat(ui): swipe to star/unstar, swipe to unread (#594)
* feat(ui): swipe to star & unstar * feat(ui): swipe to unread * feat(ui): add haptic feedback to swipe gesture * fix(ui): disable swipe gestures when scroll in progress * feat(ui): configure swipe gestures * fix(ui): workaround for swipe animation & remove text label * fix(ui): app initialize with toggle starred
This commit is contained in:
parent
c21e22d91b
commit
8c11757be4
@ -76,6 +76,8 @@ fun Preferences.toSettings(): Settings {
|
|||||||
// Interaction
|
// Interaction
|
||||||
initialPage = InitialPagePreference.fromPreferences(this),
|
initialPage = InitialPagePreference.fromPreferences(this),
|
||||||
initialFilter = InitialFilterPreference.fromPreferences(this),
|
initialFilter = InitialFilterPreference.fromPreferences(this),
|
||||||
|
swipeStartAction = SwipeStartActionPreference.fromPreferences(this),
|
||||||
|
swipeEndAction = SwipeEndActionPreference.fromPreferences(this),
|
||||||
openLink = OpenLinkPreference.fromPreferences(this),
|
openLink = OpenLinkPreference.fromPreferences(this),
|
||||||
openLinkSpecificBrowser = OpenLinkSpecificBrowserPreference.fromPreferences(this),
|
openLinkSpecificBrowser = OpenLinkSpecificBrowserPreference.fromPreferences(this),
|
||||||
|
|
||||||
|
@ -75,6 +75,8 @@ data class Settings(
|
|||||||
// Interaction
|
// Interaction
|
||||||
val initialPage: InitialPagePreference = InitialPagePreference.default,
|
val initialPage: InitialPagePreference = InitialPagePreference.default,
|
||||||
val initialFilter: InitialFilterPreference = InitialFilterPreference.default,
|
val initialFilter: InitialFilterPreference = InitialFilterPreference.default,
|
||||||
|
val swipeStartAction: SwipeStartActionPreference = SwipeStartActionPreference.default,
|
||||||
|
val swipeEndAction: SwipeEndActionPreference = SwipeEndActionPreference.default,
|
||||||
val openLink: OpenLinkPreference = OpenLinkPreference.default,
|
val openLink: OpenLinkPreference = OpenLinkPreference.default,
|
||||||
val openLinkSpecificBrowser: OpenLinkSpecificBrowserPreference = OpenLinkSpecificBrowserPreference.default,
|
val openLinkSpecificBrowser: OpenLinkSpecificBrowserPreference = OpenLinkSpecificBrowserPreference.default,
|
||||||
|
|
||||||
@ -177,6 +179,8 @@ val LocalReadingImageMaximize =
|
|||||||
val LocalInitialPage = compositionLocalOf<InitialPagePreference> { InitialPagePreference.default }
|
val LocalInitialPage = compositionLocalOf<InitialPagePreference> { InitialPagePreference.default }
|
||||||
val LocalInitialFilter =
|
val LocalInitialFilter =
|
||||||
compositionLocalOf<InitialFilterPreference> { InitialFilterPreference.default }
|
compositionLocalOf<InitialFilterPreference> { InitialFilterPreference.default }
|
||||||
|
val LocalArticleListSwipeEndAction = compositionLocalOf { SwipeEndActionPreference.default }
|
||||||
|
val LocalArticleListSwipeStartAction = compositionLocalOf { SwipeStartActionPreference.default }
|
||||||
val LocalOpenLink =
|
val LocalOpenLink =
|
||||||
compositionLocalOf<OpenLinkPreference> { OpenLinkPreference.default }
|
compositionLocalOf<OpenLinkPreference> { OpenLinkPreference.default }
|
||||||
val LocalOpenLinkSpecificBrowser =
|
val LocalOpenLinkSpecificBrowser =
|
||||||
@ -263,6 +267,8 @@ fun SettingsProvider(
|
|||||||
// Interaction
|
// Interaction
|
||||||
LocalInitialPage provides settings.initialPage,
|
LocalInitialPage provides settings.initialPage,
|
||||||
LocalInitialFilter provides settings.initialFilter,
|
LocalInitialFilter provides settings.initialFilter,
|
||||||
|
LocalArticleListSwipeStartAction provides settings.swipeStartAction,
|
||||||
|
LocalArticleListSwipeEndAction provides settings.swipeEndAction,
|
||||||
LocalOpenLink provides settings.openLink,
|
LocalOpenLink provides settings.openLink,
|
||||||
LocalOpenLinkSpecificBrowser provides settings.openLinkSpecificBrowser,
|
LocalOpenLinkSpecificBrowser provides settings.openLinkSpecificBrowser,
|
||||||
|
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
package me.ash.reader.infrastructure.preference
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.ash.reader.R
|
||||||
|
import me.ash.reader.ui.ext.DataStoreKeys
|
||||||
|
import me.ash.reader.ui.ext.dataStore
|
||||||
|
import me.ash.reader.ui.ext.put
|
||||||
|
|
||||||
|
data object SwipeGestureActions {
|
||||||
|
const val None = 0
|
||||||
|
const val ToggleRead = 1
|
||||||
|
const val ToggleStarred = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SwipeEndActionPreference(val action: Int) : Preference() {
|
||||||
|
override fun put(context: Context, scope: CoroutineScope) {
|
||||||
|
scope.launch {
|
||||||
|
context.dataStore.put(
|
||||||
|
DataStoreKeys.SwipeEndAction, action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object None : SwipeEndActionPreference(SwipeGestureActions.None)
|
||||||
|
data object ToggleRead : SwipeEndActionPreference(SwipeGestureActions.ToggleRead)
|
||||||
|
data object ToggleStarred :
|
||||||
|
SwipeEndActionPreference(SwipeGestureActions.ToggleStarred)
|
||||||
|
|
||||||
|
val desc: String
|
||||||
|
@Composable get() = when (this) {
|
||||||
|
None -> stringResource(id = R.string.none)
|
||||||
|
ToggleRead -> stringResource(id = R.string.toggle_read)
|
||||||
|
ToggleStarred -> stringResource(id = R.string.toggle_starred)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val default: SwipeEndActionPreference = ToggleRead
|
||||||
|
val values = listOf(
|
||||||
|
None,
|
||||||
|
ToggleRead,
|
||||||
|
ToggleStarred
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fromPreferences(preferences: Preferences): SwipeEndActionPreference {
|
||||||
|
return when (preferences[DataStoreKeys.SwipeEndAction.key]) {
|
||||||
|
SwipeGestureActions.None -> None
|
||||||
|
SwipeGestureActions.ToggleRead -> ToggleRead
|
||||||
|
SwipeGestureActions.ToggleStarred -> ToggleStarred
|
||||||
|
else -> default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SwipeStartActionPreference(val action: Int) : Preference() {
|
||||||
|
override fun put(context: Context, scope: CoroutineScope) {
|
||||||
|
scope.launch {
|
||||||
|
context.dataStore.put(
|
||||||
|
DataStoreKeys.SwipeStartAction, action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object None : SwipeStartActionPreference(SwipeGestureActions.None)
|
||||||
|
data object ToggleRead : SwipeStartActionPreference(SwipeGestureActions.ToggleRead)
|
||||||
|
data object ToggleStarred :
|
||||||
|
SwipeStartActionPreference(SwipeGestureActions.ToggleStarred)
|
||||||
|
|
||||||
|
val desc: String
|
||||||
|
@Composable get() = when (this) {
|
||||||
|
None -> stringResource(id = R.string.none)
|
||||||
|
ToggleRead -> stringResource(id = R.string.toggle_read)
|
||||||
|
ToggleStarred -> stringResource(id = R.string.toggle_starred)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val default: SwipeStartActionPreference = ToggleStarred
|
||||||
|
val values = listOf(
|
||||||
|
None,
|
||||||
|
ToggleRead,
|
||||||
|
ToggleStarred
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fromPreferences(preferences: Preferences): SwipeStartActionPreference {
|
||||||
|
return when (preferences[DataStoreKeys.SwipeStartAction.key]) {
|
||||||
|
SwipeGestureActions.None -> None
|
||||||
|
SwipeGestureActions.ToggleRead -> ToggleRead
|
||||||
|
SwipeGestureActions.ToggleStarred -> ToggleStarred
|
||||||
|
else -> default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -404,6 +404,18 @@ sealed class DataStoreKeys<T> {
|
|||||||
get() = intPreferencesKey("initialFilter")
|
get() = intPreferencesKey("initialFilter")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data object SwipeStartAction : DataStoreKeys<Int>() {
|
||||||
|
|
||||||
|
override val key: Preferences.Key<Int>
|
||||||
|
get() = intPreferencesKey("swipeStartAction")
|
||||||
|
}
|
||||||
|
|
||||||
|
data object SwipeEndAction : DataStoreKeys<Int>() {
|
||||||
|
|
||||||
|
override val key: Preferences.Key<Int>
|
||||||
|
get() = intPreferencesKey("swipeEndAction")
|
||||||
|
}
|
||||||
|
|
||||||
object OpenLink : DataStoreKeys<Int>() {
|
object OpenLink : DataStoreKeys<Int>() {
|
||||||
|
|
||||||
override val key: Preferences.Key<Int>
|
override val key: Preferences.Key<Int>
|
||||||
|
@ -1,21 +1,51 @@
|
|||||||
package me.ash.reader.ui.page.home.flow
|
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.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.material.*
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Circle
|
||||||
|
import androidx.compose.material.icons.outlined.StarOutline
|
||||||
import androidx.compose.material.icons.rounded.CheckCircleOutline
|
import androidx.compose.material.icons.rounded.CheckCircleOutline
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@ -24,7 +54,18 @@ import coil.size.Precision
|
|||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import me.ash.reader.R
|
import me.ash.reader.R
|
||||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||||
import me.ash.reader.infrastructure.preference.*
|
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
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListDesc
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedName
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListImage
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListReadIndicator
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalFlowArticleListTime
|
||||||
|
import me.ash.reader.infrastructure.preference.SwipeEndActionPreference
|
||||||
|
import me.ash.reader.infrastructure.preference.SwipeStartActionPreference
|
||||||
import me.ash.reader.ui.component.FeedIcon
|
import me.ash.reader.ui.component.FeedIcon
|
||||||
import me.ash.reader.ui.component.base.RYAsyncImage
|
import me.ash.reader.ui.component.base.RYAsyncImage
|
||||||
import me.ash.reader.ui.component.base.SIZE_1000
|
import me.ash.reader.ui.component.base.SIZE_1000
|
||||||
@ -50,8 +91,7 @@ fun ArticleItem(
|
|||||||
.clip(Shape20)
|
.clip(Shape20)
|
||||||
.clickable { onClick(articleWithFeed) }
|
.clickable { onClick(articleWithFeed) }
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||||
.alpha(
|
.alpha(articleWithFeed.article.run {
|
||||||
articleWithFeed.article.run {
|
|
||||||
when (articleListReadIndicator) {
|
when (articleListReadIndicator) {
|
||||||
FlowArticleReadIndicatorPreference.AllRead -> {
|
FlowArticleReadIndicatorPreference.AllRead -> {
|
||||||
if (isUnread) 1f else 0.5f
|
if (isUnread) 1f else 0.5f
|
||||||
@ -61,8 +101,7 @@ fun ArticleItem(
|
|||||||
if (isUnread || isStarred) 1f else 0.5f
|
if (isUnread || isStarred) 1f else 0.5f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
// Top
|
// Top
|
||||||
Row(
|
Row(
|
||||||
@ -170,56 +209,89 @@ fun ArticleItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalMaterialApi
|
private const val PositionalThresholdFraction = 0.15f
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SwipeableArticleItem(
|
fun SwipeableArticleItem(
|
||||||
articleWithFeed: ArticleWithFeed,
|
articleWithFeed: ArticleWithFeed,
|
||||||
isFilterUnread: Boolean,
|
isFilterUnread: Boolean,
|
||||||
articleListTonalElevation: Int,
|
articleListTonalElevation: Int,
|
||||||
onClick: (ArticleWithFeed) -> Unit = {},
|
onClick: (ArticleWithFeed) -> Unit = {},
|
||||||
onSwipeOut: (ArticleWithFeed) -> Unit = {},
|
isScrollInProgress: () -> Boolean = { false },
|
||||||
|
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
|
||||||
|
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var isArticleVisible by remember { mutableStateOf(true) }
|
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||||
val dismissState =
|
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||||
rememberDismissState(initialValue = DismissValue.Default, confirmStateChange = {
|
|
||||||
if (it == DismissValue.DismissedToEnd) {
|
val density = LocalDensity.current
|
||||||
isArticleVisible = !isFilterUnread
|
val confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = {
|
||||||
onSwipeOut(articleWithFeed)
|
when (it) {
|
||||||
}
|
SwipeToDismissBoxValue.StartToEnd -> {
|
||||||
isFilterUnread
|
onSwipeStartToEnd?.invoke(articleWithFeed)
|
||||||
})
|
swipeToEndAction == SwipeEndActionPreference.ToggleRead && isFilterUnread
|
||||||
if (isArticleVisible) {
|
|
||||||
SwipeToDismiss(
|
|
||||||
state = dismissState,
|
|
||||||
/*** create dismiss alert background box */
|
|
||||||
background = {
|
|
||||||
if (dismissState.dismissDirection == DismissDirection.StartToEnd) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// .background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(24.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.align(Alignment.CenterStart)) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.CheckCircleOutline,
|
|
||||||
contentDescription = stringResource(R.string.mark_as_read),
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mark_as_read),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> {
|
||||||
|
onSwipeEndToStart?.invoke(articleWithFeed)
|
||||||
|
swipeToStartAction == SwipeStartActionPreference.ToggleRead && isFilterUnread
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.Settled -> {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val positionalThreshold: (totalDistance: Float) -> Float = {
|
||||||
|
it * PositionalThresholdFraction
|
||||||
|
}
|
||||||
|
val velocityThreshold: () -> Float = { Float.POSITIVE_INFINITY }
|
||||||
|
val animationSpec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow)
|
||||||
|
val swipeState = rememberSaveable(
|
||||||
|
articleWithFeed.article, saver = SwipeToDismissBoxState.Saver(
|
||||||
|
confirmValueChange = confirmValueChange,
|
||||||
|
density = density,
|
||||||
|
animationSpec = animationSpec,
|
||||||
|
velocityThreshold = velocityThreshold,
|
||||||
|
positionalThreshold = positionalThreshold
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
SwipeToDismissBoxState(
|
||||||
|
initialValue = SwipeToDismissBoxValue.Settled,
|
||||||
|
density = density,
|
||||||
|
animationSpec = animationSpec,
|
||||||
|
confirmValueChange = confirmValueChange,
|
||||||
|
positionalThreshold = positionalThreshold,
|
||||||
|
velocityThreshold = velocityThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val view = LocalView.current
|
||||||
|
var isActive 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)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = swipeState,
|
||||||
|
enabled = !isScrollInProgress(),
|
||||||
|
/*** create dismiss alert background box */
|
||||||
|
backgroundContent = {
|
||||||
|
SwipeToDismissBoxBackgroundContent(
|
||||||
|
direction = swipeState.dismissDirection,
|
||||||
|
isActive = isActive,
|
||||||
|
isStarred = articleWithFeed.article.isStarred,
|
||||||
|
isRead = !articleWithFeed.article.isUnread
|
||||||
|
)
|
||||||
},
|
},
|
||||||
/**** Dismiss Content */
|
/**** Dismiss Content */
|
||||||
dismissContent = {
|
content = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -233,7 +305,116 @@ fun SwipeableArticleItem(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
/*** Set Direction to dismiss */
|
/*** Set Direction to dismiss */
|
||||||
directions = setOf(DismissDirection.StartToEnd),
|
enableDismissFromEndToStart = onSwipeEndToStart != null,
|
||||||
|
enableDismissFromStartToEnd = onSwipeStartToEnd != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SwipeToDismissBoxBackgroundContent(
|
||||||
|
direction: SwipeToDismissBoxValue,
|
||||||
|
isActive: Boolean,
|
||||||
|
isStarred: Boolean,
|
||||||
|
isRead: Boolean,
|
||||||
|
) {
|
||||||
|
val containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
val containerColorElevated = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
val backgroundColor = remember { Animatable(containerColor) }
|
||||||
|
|
||||||
|
LaunchedEffect(isActive) {
|
||||||
|
backgroundColor.animateTo(
|
||||||
|
if (isActive) {
|
||||||
|
containerColorElevated
|
||||||
|
} else {
|
||||||
|
containerColor
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val alignment = when (direction) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd
|
||||||
|
SwipeToDismissBoxValue.Settled -> Alignment.Center
|
||||||
|
}
|
||||||
|
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||||
|
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||||
|
|
||||||
|
val starImageVector =
|
||||||
|
remember(isStarred) { if (isStarred) Icons.Outlined.StarOutline else Icons.Rounded.Star }
|
||||||
|
|
||||||
|
val readImageVector =
|
||||||
|
remember(isRead) { if (isRead) Icons.Outlined.Circle else Icons.Rounded.CheckCircleOutline }
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
val imageVector = remember(direction) {
|
||||||
|
when (direction) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> {
|
||||||
|
|
||||||
|
when (swipeToEndAction) {
|
||||||
|
SwipeEndActionPreference.None -> null
|
||||||
|
SwipeEndActionPreference.ToggleRead -> readImageVector
|
||||||
|
SwipeEndActionPreference.ToggleStarred -> starImageVector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> {
|
||||||
|
when (swipeToStartAction) {
|
||||||
|
SwipeStartActionPreference.None -> null
|
||||||
|
SwipeStartActionPreference.ToggleRead -> readImageVector
|
||||||
|
SwipeStartActionPreference.ToggleStarred -> starImageVector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.Settled -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val text = remember(direction) {
|
||||||
|
when (direction) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> {
|
||||||
|
when (swipeToEndAction) {
|
||||||
|
SwipeEndActionPreference.None -> null
|
||||||
|
SwipeEndActionPreference.ToggleRead -> readText
|
||||||
|
SwipeEndActionPreference.ToggleStarred -> starText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> {
|
||||||
|
when (swipeToStartAction) {
|
||||||
|
SwipeStartActionPreference.None -> null
|
||||||
|
SwipeStartActionPreference.ToggleRead -> readText
|
||||||
|
SwipeStartActionPreference.ToggleStarred -> starText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBoxValue.Settled -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawBehind { drawRect(backgroundColor.value) },
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.align(alignment = alignment)) {
|
||||||
|
imageVector?.let {
|
||||||
|
Icon(
|
||||||
|
imageVector = it,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -4,7 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
@ -12,29 +11,33 @@ import me.ash.reader.domain.model.article.ArticleFlowItem
|
|||||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||||
|
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun LazyListScope.ArticleList(
|
fun LazyListScope.ArticleList(
|
||||||
pagingItems: LazyPagingItems<ArticleFlowItem>,
|
pagingItems: LazyPagingItems<ArticleFlowItem>,
|
||||||
isFilterUnread: Boolean,
|
isFilterUnread: Boolean,
|
||||||
isShowFeedIcon: Boolean,
|
isShowFeedIcon: Boolean,
|
||||||
isShowStickyHeader: Boolean,
|
isShowStickyHeader: Boolean,
|
||||||
articleListTonalElevation: Int,
|
articleListTonalElevation: Int,
|
||||||
|
isScrollInProgress: () -> Boolean = { false },
|
||||||
onClick: (ArticleWithFeed) -> Unit = {},
|
onClick: (ArticleWithFeed) -> Unit = {},
|
||||||
onSwipeOut: (ArticleWithFeed) -> Unit = {}
|
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
|
||||||
|
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
for (index in 0 until pagingItems.itemCount) {
|
for (index in 0 until pagingItems.itemCount) {
|
||||||
when (val item = pagingItems.peek(index)) {
|
when (val item = pagingItems.peek(index)) {
|
||||||
is ArticleFlowItem.Article -> {
|
is ArticleFlowItem.Article -> {
|
||||||
item(key = item.articleWithFeed.article.id) {
|
item(key = item.articleWithFeed.article.id) {
|
||||||
if (item.articleWithFeed.article.isUnread) {
|
// if (item.articleWithFeed.article.isUnread) {
|
||||||
SwipeableArticleItem(
|
SwipeableArticleItem(
|
||||||
articleWithFeed = item.articleWithFeed,
|
articleWithFeed = item.articleWithFeed,
|
||||||
isFilterUnread = isFilterUnread,
|
isFilterUnread = isFilterUnread,
|
||||||
articleListTonalElevation = articleListTonalElevation,
|
articleListTonalElevation = articleListTonalElevation,
|
||||||
onClick = { onClick(it) },
|
onClick = { onClick(it) },
|
||||||
onSwipeOut = { onSwipeOut(it) }
|
isScrollInProgress = isScrollInProgress,
|
||||||
|
onSwipeStartToEnd = onSwipeStartToEnd,
|
||||||
|
onSwipeEndToStart = onSwipeEndToStart
|
||||||
)
|
)
|
||||||
} else {
|
/* } else {
|
||||||
// Currently we don't have swipe left to mark as unread,
|
// Currently we don't have swipe left to mark as unread,
|
||||||
// so [SwipeableArticleItem] is not necessary for read articles.
|
// so [SwipeableArticleItem] is not necessary for read articles.
|
||||||
ArticleItem(
|
ArticleItem(
|
||||||
@ -42,7 +45,7 @@ fun LazyListScope.ArticleList(
|
|||||||
) {
|
) {
|
||||||
onClick(it)
|
onClick(it)
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ import androidx.work.WorkInfo
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.ash.reader.R
|
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.Filter
|
||||||
import me.ash.reader.domain.model.general.MarkAsReadConditions
|
import me.ash.reader.domain.model.general.MarkAsReadConditions
|
||||||
import me.ash.reader.infrastructure.preference.*
|
import me.ash.reader.infrastructure.preference.*
|
||||||
@ -52,6 +52,8 @@ fun FlowPage(
|
|||||||
val filterBarFilled = LocalFlowFilterBarFilled.current
|
val filterBarFilled = LocalFlowFilterBarFilled.current
|
||||||
val filterBarPadding = LocalFlowFilterBarPadding.current
|
val filterBarPadding = LocalFlowFilterBarPadding.current
|
||||||
val filterBarTonalElevation = LocalFlowFilterBarTonalElevation.current
|
val filterBarTonalElevation = LocalFlowFilterBarTonalElevation.current
|
||||||
|
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||||
|
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||||
|
|
||||||
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
|
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
|
||||||
val flowUiState = flowViewModel.flowUiState.collectAsStateValue()
|
val flowUiState = flowViewModel.flowUiState.collectAsStateValue()
|
||||||
@ -71,6 +73,37 @@ fun FlowPage(
|
|||||||
it?.let { isSyncing = it.any { it.state == WorkInfo.State.RUNNING } }
|
it?.let { isSyncing = it.any { it.state == WorkInfo.State.RUNNING } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onToggleStarred: State<(ArticleWithFeed) -> Unit> = rememberUpdatedState {
|
||||||
|
flowViewModel.updateStarredStatus(
|
||||||
|
articleId = it.article.id,
|
||||||
|
isStarred = !it.article.isStarred,
|
||||||
|
withDelay = 300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onToggleRead: State<(ArticleWithFeed) -> Unit> = rememberUpdatedState {
|
||||||
|
flowViewModel.updateReadStatus(
|
||||||
|
groupId = null,
|
||||||
|
feedId = null,
|
||||||
|
articleId = it.article.id,
|
||||||
|
conditions = MarkAsReadConditions.All,
|
||||||
|
isUnread = !it.article.isUnread,
|
||||||
|
withDelay = 300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onSwipeEndToStart = when (swipeToStartAction) {
|
||||||
|
SwipeStartActionPreference.None -> null
|
||||||
|
SwipeStartActionPreference.ToggleRead -> onToggleRead.value
|
||||||
|
SwipeStartActionPreference.ToggleStarred -> onToggleStarred.value
|
||||||
|
}
|
||||||
|
|
||||||
|
val onSwipeStartToEnd = when (swipeToEndAction) {
|
||||||
|
SwipeEndActionPreference.None -> null
|
||||||
|
SwipeEndActionPreference.ToggleRead -> onToggleRead.value
|
||||||
|
SwipeEndActionPreference.ToggleStarred -> onToggleStarred.value
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(onSearch) {
|
LaunchedEffect(onSearch) {
|
||||||
snapshotFlow { onSearch }.collect {
|
snapshotFlow { onSearch }.collect {
|
||||||
if (it) {
|
if (it) {
|
||||||
@ -192,11 +225,12 @@ fun FlowPage(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
markAsRead = false
|
markAsRead = false
|
||||||
flowViewModel.markAsRead(
|
flowViewModel.updateReadStatus(
|
||||||
groupId = filterUiState.group?.id,
|
groupId = filterUiState.group?.id,
|
||||||
feedId = filterUiState.feed?.id,
|
feedId = filterUiState.feed?.id,
|
||||||
articleId = null,
|
articleId = null,
|
||||||
conditions = it,
|
conditions = it,
|
||||||
|
isUnread = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
RYExtensibleVisibility(visible = onSearch) {
|
RYExtensibleVisibility(visible = onSearch) {
|
||||||
@ -238,20 +272,16 @@ fun FlowPage(
|
|||||||
isShowFeedIcon = articleListFeedIcon.value,
|
isShowFeedIcon = articleListFeedIcon.value,
|
||||||
isShowStickyHeader = articleListDateStickyHeader.value,
|
isShowStickyHeader = articleListDateStickyHeader.value,
|
||||||
articleListTonalElevation = articleListTonalElevation.value,
|
articleListTonalElevation = articleListTonalElevation.value,
|
||||||
|
isScrollInProgress = { listState.isScrollInProgress },
|
||||||
onClick = {
|
onClick = {
|
||||||
onSearch = false
|
onSearch = false
|
||||||
navController.navigate("${RouteName.READING}/${it.article.id}") {
|
navController.navigate("${RouteName.READING}/${it.article.id}") {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) {
|
onSwipeStartToEnd = onSwipeStartToEnd,
|
||||||
flowViewModel.markAsRead(
|
onSwipeEndToStart = onSwipeEndToStart
|
||||||
groupId = null,
|
|
||||||
feedId = null,
|
|
||||||
articleId = it.article.id,
|
|
||||||
MarkAsReadConditions.All
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(128.dp))
|
Spacer(modifier = Modifier.height(128.dp))
|
||||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||||
|
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -33,22 +34,42 @@ class FlowViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsRead(
|
fun updateReadStatus(
|
||||||
groupId: String?,
|
groupId: String?,
|
||||||
feedId: String?,
|
feedId: String?,
|
||||||
articleId: String?,
|
articleId: String?,
|
||||||
conditions: MarkAsReadConditions,
|
conditions: MarkAsReadConditions,
|
||||||
|
isUnread: Boolean,
|
||||||
|
withDelay: Long = 0,
|
||||||
) {
|
) {
|
||||||
applicationScope.launch(ioDispatcher) {
|
applicationScope.launch(ioDispatcher) {
|
||||||
|
delay(withDelay)
|
||||||
rssService.get().markAsRead(
|
rssService.get().markAsRead(
|
||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
feedId = feedId,
|
feedId = feedId,
|
||||||
articleId = articleId,
|
articleId = articleId,
|
||||||
before = conditions.toDate(),
|
before = conditions.toDate(),
|
||||||
isUnread = false,
|
isUnread = isUnread,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
isStarred = isStarred,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FlowUiState(
|
data class FlowUiState(
|
||||||
|
@ -0,0 +1,471 @@
|
|||||||
|
package me.ash.reader.ui.page.home.flow
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||||
|
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||||
|
import androidx.compose.foundation.gestures.animateTo
|
||||||
|
import androidx.compose.foundation.gestures.snapTo
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxState.Companion.Saver
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.Measurable
|
||||||
|
import androidx.compose.ui.layout.MeasureResult
|
||||||
|
import androidx.compose.ui.layout.MeasureScope
|
||||||
|
import androidx.compose.ui.node.LayoutModifierNode
|
||||||
|
import androidx.compose.ui.node.ModifierNodeElement
|
||||||
|
import androidx.compose.ui.platform.InspectorInfo
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.debugInspectorInfo
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directions in which a [SwipeToDismissBox] can be dismissed.
|
||||||
|
*/
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
enum class SwipeToDismissBoxValue {
|
||||||
|
/**
|
||||||
|
* Can be dismissed by swiping in the reading direction.
|
||||||
|
*/
|
||||||
|
StartToEnd,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be dismissed by swiping in the reverse of the reading direction.
|
||||||
|
*/
|
||||||
|
EndToStart,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cannot currently be dismissed.
|
||||||
|
*/
|
||||||
|
Settled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the [SwipeToDismissBox] composable.
|
||||||
|
*
|
||||||
|
* @param initialValue The initial value of the state.
|
||||||
|
* @param density The density that this state can use to convert values to and from dp.
|
||||||
|
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||||
|
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||||
|
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||||
|
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||||
|
* subtracted from/to the origin offset. It should always be a positive value.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
class SwipeToDismissBoxState(
|
||||||
|
initialValue: SwipeToDismissBoxValue,
|
||||||
|
internal val density: Density,
|
||||||
|
animationSpec: AnimationSpec<Float> = spring(),
|
||||||
|
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||||
|
velocityThreshold: () -> Float = { with(density) { DismissThreshold.toPx() } },
|
||||||
|
positionalThreshold: (totalDistance: Float) -> Float
|
||||||
|
) {
|
||||||
|
internal val anchoredDraggableState = AnchoredDraggableState(
|
||||||
|
initialValue = initialValue,
|
||||||
|
animationSpec = animationSpec,
|
||||||
|
confirmValueChange = confirmValueChange,
|
||||||
|
positionalThreshold = positionalThreshold,
|
||||||
|
velocityThreshold = velocityThreshold
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val offset: Float get() = anchoredDraggableState.offset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require the current offset.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException If the offset has not been initialized yet
|
||||||
|
*/
|
||||||
|
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state value of the [SwipeToDismissBoxState].
|
||||||
|
*/
|
||||||
|
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target state. This is the closest state to the current offset (taking into account
|
||||||
|
* positional thresholds). If no interactions like animations or drags are in progress, this
|
||||||
|
* will be the current state.
|
||||||
|
*/
|
||||||
|
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
|
||||||
|
*/
|
||||||
|
@get:FloatRange(from = 0.0, to = 1.0)
|
||||||
|
val progress: Float get() = anchoredDraggableState.progress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direction (if any) in which the composable has been or is being dismissed.
|
||||||
|
*
|
||||||
|
* Use this to change the background of the [SwipeToDismissBox] if you want different actions on each
|
||||||
|
* side.
|
||||||
|
*/
|
||||||
|
val dismissDirection: SwipeToDismissBoxValue
|
||||||
|
get() = if (offset == 0f || offset.isNaN())
|
||||||
|
SwipeToDismissBoxValue.Settled
|
||||||
|
else if (offset > 0f)
|
||||||
|
SwipeToDismissBoxValue.StartToEnd else SwipeToDismissBoxValue.EndToStart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the component has been dismissed in the given [direction].
|
||||||
|
*
|
||||||
|
* @param direction The dismiss direction.
|
||||||
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message = "DismissDirection is no longer used by SwipeToDismissBoxState. Please compare " +
|
||||||
|
"currentValue against SwipeToDismissValue instead.",
|
||||||
|
level = DeprecationLevel.HIDDEN
|
||||||
|
)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun isDismissed(direction: DismissDirection): Boolean {
|
||||||
|
return currentValue == (
|
||||||
|
if (direction == DismissDirection.StartToEnd) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd
|
||||||
|
} else {
|
||||||
|
SwipeToDismissBoxValue.EndToStart
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the state without any animation and suspend until it's set
|
||||||
|
*
|
||||||
|
* @param targetValue The new target value
|
||||||
|
*/
|
||||||
|
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
|
||||||
|
anchoredDraggableState.snapTo(targetValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the component to the default position with animation and suspend until it if fully
|
||||||
|
* reset or animation has been cancelled. This method will throw [CancellationException] if
|
||||||
|
* the animation is interrupted
|
||||||
|
*
|
||||||
|
* @return the reason the reset animation ended
|
||||||
|
*/
|
||||||
|
suspend fun reset() = anchoredDraggableState.animateTo(
|
||||||
|
targetValue = SwipeToDismissBoxValue.Settled
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the component in the given [direction], with an animation and suspend. This method
|
||||||
|
* will throw [CancellationException] if the animation is interrupted
|
||||||
|
*
|
||||||
|
* @param direction The dismiss direction.
|
||||||
|
*/
|
||||||
|
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
|
||||||
|
anchoredDraggableState.animateTo(targetValue = direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default [Saver] implementation for [SwipeToDismissBoxState].
|
||||||
|
*/
|
||||||
|
fun Saver(
|
||||||
|
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
|
||||||
|
positionalThreshold: (totalDistance: Float) -> Float,
|
||||||
|
velocityThreshold: () -> Float,
|
||||||
|
animationSpec: AnimationSpec<Float>,
|
||||||
|
density: Density
|
||||||
|
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
|
||||||
|
save = { it.currentValue },
|
||||||
|
restore = {
|
||||||
|
SwipeToDismissBoxState(
|
||||||
|
it,
|
||||||
|
density,
|
||||||
|
animationSpec,
|
||||||
|
confirmValueChange,
|
||||||
|
velocityThreshold,
|
||||||
|
positionalThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that can be dismissed by swiping left or right.
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.material3.samples.SwipeToDismissListItems
|
||||||
|
*
|
||||||
|
* @param state The state of this component.
|
||||||
|
* @param background A composable that is stacked behind the content and is exposed when the
|
||||||
|
* content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||||
|
* @param dismissContent The content that can be dismissed.
|
||||||
|
* @param modifier Optional [Modifier] for this component.
|
||||||
|
* @param directions The set of directions in which the component can be dismissed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@Deprecated(
|
||||||
|
level = DeprecationLevel.WARNING,
|
||||||
|
message = "Use SwipeToDismissBox instead",
|
||||||
|
replaceWith =
|
||||||
|
ReplaceWith(
|
||||||
|
"SwipeToDismissBox(state, background, modifier, " +
|
||||||
|
"enableDismissFromStartToEnd, enableDismissFromEndToStart, dismissContent)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
fun SwipeToDismiss(
|
||||||
|
state: SwipeToDismissBoxState,
|
||||||
|
background: @Composable RowScope.() -> Unit,
|
||||||
|
dismissContent: @Composable RowScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
directions: Set<SwipeToDismissBoxValue> = setOf(
|
||||||
|
SwipeToDismissBoxValue.EndToStart,
|
||||||
|
SwipeToDismissBoxValue.StartToEnd
|
||||||
|
),
|
||||||
|
) = SwipeToDismissBox(
|
||||||
|
state = state,
|
||||||
|
backgroundContent = background,
|
||||||
|
modifier = modifier,
|
||||||
|
enableDismissFromStartToEnd = SwipeToDismissBoxValue.StartToEnd in directions,
|
||||||
|
enableDismissFromEndToStart = SwipeToDismissBoxValue.EndToStart in directions,
|
||||||
|
content = dismissContent
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that can be dismissed by swiping left or right.
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.material3.samples.SwipeToDismissListItems
|
||||||
|
*
|
||||||
|
* @param state The state of this component.
|
||||||
|
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when the
|
||||||
|
* content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||||
|
* @param modifier Optional [Modifier] for this component.
|
||||||
|
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
|
||||||
|
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
|
||||||
|
* @param content The content that can be dismissed.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
fun SwipeToDismissBox(
|
||||||
|
state: SwipeToDismissBoxState,
|
||||||
|
backgroundContent: @Composable RowScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
enableDismissFromStartToEnd: Boolean = true,
|
||||||
|
enableDismissFromEndToStart: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.anchoredDraggable(
|
||||||
|
state = state.anchoredDraggableState,
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
enabled = enabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||||
|
reverseDirection = isRtl,
|
||||||
|
),
|
||||||
|
propagateMinConstraints = true
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
content = backgroundContent,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
content = content,
|
||||||
|
modifier = Modifier.swipeToDismissBoxAnchors(
|
||||||
|
state,
|
||||||
|
enableDismissFromStartToEnd,
|
||||||
|
enableDismissFromEndToStart
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contains default values for [SwipeToDismissBox] and [SwipeToDismissBoxState]. */
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
object SwipeToDismissBoxDefaults {
|
||||||
|
/** Default positional threshold of 56.dp for [SwipeToDismissBoxState]. */
|
||||||
|
val positionalThreshold: (totalDistance: Float) -> Float
|
||||||
|
@Composable get() = with(LocalDensity.current) {
|
||||||
|
{ 56.dp.toPx() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directions in which a [SwipeToDismissBox] can be dismissed.
|
||||||
|
*/
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
@Deprecated(
|
||||||
|
message = "Dismiss direction is no longer used by SwipeToDismissBoxState. Please use " +
|
||||||
|
"SwipeToDismissBoxValue instead.",
|
||||||
|
level = DeprecationLevel.WARNING
|
||||||
|
)
|
||||||
|
enum class DismissDirection {
|
||||||
|
/**
|
||||||
|
* Can be dismissed by swiping in the reading direction.
|
||||||
|
*/
|
||||||
|
StartToEnd,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be dismissed by swiping in the reverse of the reading direction.
|
||||||
|
*/
|
||||||
|
EndToStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible values of [SwipeToDismissBoxState].
|
||||||
|
*/
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
@Deprecated(
|
||||||
|
message = "DismissValue is no longer used by SwipeToDismissBoxState. Please use " +
|
||||||
|
"SwipeToDismissBoxValue instead.",
|
||||||
|
level = DeprecationLevel.WARNING
|
||||||
|
)
|
||||||
|
enum class DismissValue {
|
||||||
|
/**
|
||||||
|
* Indicates the component has not been dismissed yet.
|
||||||
|
*/
|
||||||
|
Default,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the component has been dismissed in the reading direction.
|
||||||
|
*/
|
||||||
|
DismissedToEnd,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the component has been dismissed in the reverse of the reading direction.
|
||||||
|
*/
|
||||||
|
DismissedToStart
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DismissThreshold = 125.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private fun Modifier.swipeToDismissBoxAnchors(
|
||||||
|
state: SwipeToDismissBoxState,
|
||||||
|
enableDismissFromStartToEnd: Boolean,
|
||||||
|
enableDismissFromEndToStart: Boolean
|
||||||
|
) = this then SwipeToDismissAnchorsElement(
|
||||||
|
state,
|
||||||
|
enableDismissFromStartToEnd,
|
||||||
|
enableDismissFromEndToStart
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private class SwipeToDismissAnchorsElement(
|
||||||
|
private val state: SwipeToDismissBoxState,
|
||||||
|
private val enableDismissFromStartToEnd: Boolean,
|
||||||
|
private val enableDismissFromEndToStart: Boolean,
|
||||||
|
) : ModifierNodeElement<SwipeToDismissAnchorsNode>() {
|
||||||
|
|
||||||
|
override fun create() = SwipeToDismissAnchorsNode(
|
||||||
|
state,
|
||||||
|
enableDismissFromStartToEnd,
|
||||||
|
enableDismissFromEndToStart,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun update(node: SwipeToDismissAnchorsNode) {
|
||||||
|
node.state = state
|
||||||
|
node.enableDismissFromStartToEnd = enableDismissFromStartToEnd
|
||||||
|
node.enableDismissFromEndToStart = enableDismissFromEndToStart
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
other as SwipeToDismissAnchorsElement
|
||||||
|
if (state != other.state) return false
|
||||||
|
if (enableDismissFromStartToEnd != other.enableDismissFromStartToEnd) return false
|
||||||
|
if (enableDismissFromEndToStart != other.enableDismissFromEndToStart) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = state.hashCode()
|
||||||
|
result = 31 * result + enableDismissFromStartToEnd.hashCode()
|
||||||
|
result = 31 * result + enableDismissFromEndToStart.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun InspectorInfo.inspectableProperties() {
|
||||||
|
debugInspectorInfo {
|
||||||
|
properties["state"] = state
|
||||||
|
properties["enableDismissFromStartToEnd"] = enableDismissFromStartToEnd
|
||||||
|
properties["enableDismissFromEndToStart"] = enableDismissFromEndToStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
private class SwipeToDismissAnchorsNode(
|
||||||
|
var state: SwipeToDismissBoxState,
|
||||||
|
var enableDismissFromStartToEnd: Boolean,
|
||||||
|
var enableDismissFromEndToStart: Boolean,
|
||||||
|
) : Modifier.Node(), LayoutModifierNode {
|
||||||
|
private var didLookahead: Boolean = false
|
||||||
|
|
||||||
|
override fun onDetach() {
|
||||||
|
didLookahead = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun MeasureScope.measure(
|
||||||
|
measurable: Measurable,
|
||||||
|
constraints: Constraints
|
||||||
|
): MeasureResult {
|
||||||
|
val placeable = measurable.measure(constraints)
|
||||||
|
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||||
|
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||||
|
// update the anchors in the main pass.
|
||||||
|
if (isLookingAhead || !didLookahead) {
|
||||||
|
val width = placeable.width.toFloat()
|
||||||
|
val newAnchors = DraggableAnchors {
|
||||||
|
SwipeToDismissBoxValue.Settled at 0f
|
||||||
|
if (enableDismissFromStartToEnd) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd at width
|
||||||
|
}
|
||||||
|
if (enableDismissFromEndToStart) {
|
||||||
|
SwipeToDismissBoxValue.EndToStart at -width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.anchoredDraggableState.updateAnchors(newAnchors)
|
||||||
|
}
|
||||||
|
didLookahead = isLookingAhead || didLookahead
|
||||||
|
return layout(placeable.width, placeable.height) {
|
||||||
|
// In a lookahead pass, we use the position of the current target as this is where any
|
||||||
|
// ongoing animations would move. If SwipeToDismissBox is in a settled state, lookahead
|
||||||
|
// and post-lookahead will converge.
|
||||||
|
val xOffset = if (isLookingAhead) {
|
||||||
|
state.anchoredDraggableState.anchors.positionOf(state.targetValue)
|
||||||
|
} else state.requireOffset()
|
||||||
|
placeable.place(xOffset.roundToInt(), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.ArrowBack
|
import androidx.compose.material.icons.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.SwipeLeft
|
||||||
|
import androidx.compose.material.icons.rounded.SwipeRight
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -24,11 +26,15 @@ import androidx.navigation.NavHostController
|
|||||||
import me.ash.reader.R
|
import me.ash.reader.R
|
||||||
import me.ash.reader.infrastructure.preference.InitialFilterPreference
|
import me.ash.reader.infrastructure.preference.InitialFilterPreference
|
||||||
import me.ash.reader.infrastructure.preference.InitialPagePreference
|
import me.ash.reader.infrastructure.preference.InitialPagePreference
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeEndAction
|
||||||
|
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeStartAction
|
||||||
import me.ash.reader.infrastructure.preference.LocalInitialFilter
|
import me.ash.reader.infrastructure.preference.LocalInitialFilter
|
||||||
import me.ash.reader.infrastructure.preference.LocalInitialPage
|
import me.ash.reader.infrastructure.preference.LocalInitialPage
|
||||||
import me.ash.reader.infrastructure.preference.LocalOpenLink
|
import me.ash.reader.infrastructure.preference.LocalOpenLink
|
||||||
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
|
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
|
||||||
import me.ash.reader.infrastructure.preference.OpenLinkPreference
|
import me.ash.reader.infrastructure.preference.OpenLinkPreference
|
||||||
|
import me.ash.reader.infrastructure.preference.SwipeEndActionPreference
|
||||||
|
import me.ash.reader.infrastructure.preference.SwipeStartActionPreference
|
||||||
import me.ash.reader.ui.component.base.DisplayText
|
import me.ash.reader.ui.component.base.DisplayText
|
||||||
import me.ash.reader.ui.component.base.FeedbackIconButton
|
import me.ash.reader.ui.component.base.FeedbackIconButton
|
||||||
import me.ash.reader.ui.component.base.RYScaffold
|
import me.ash.reader.ui.component.base.RYScaffold
|
||||||
@ -46,6 +52,8 @@ fun InteractionPage(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val initialPage = LocalInitialPage.current
|
val initialPage = LocalInitialPage.current
|
||||||
val initialFilter = LocalInitialFilter.current
|
val initialFilter = LocalInitialFilter.current
|
||||||
|
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||||
|
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||||
val openLink = LocalOpenLink.current
|
val openLink = LocalOpenLink.current
|
||||||
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
|
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -54,6 +62,8 @@ fun InteractionPage(
|
|||||||
}
|
}
|
||||||
var initialPageDialogVisible by remember { mutableStateOf(false) }
|
var initialPageDialogVisible by remember { mutableStateOf(false) }
|
||||||
var initialFilterDialogVisible by remember { mutableStateOf(false) }
|
var initialFilterDialogVisible by remember { mutableStateOf(false) }
|
||||||
|
var swipeStartDialogVisible by remember { mutableStateOf(false) }
|
||||||
|
var swipeEndDialogVisible by remember { mutableStateOf(false) }
|
||||||
var openLinkDialogVisible by remember { mutableStateOf(false) }
|
var openLinkDialogVisible by remember { mutableStateOf(false) }
|
||||||
var openLinkSpecificBrowserDialogVisible by remember { mutableStateOf(false) }
|
var openLinkSpecificBrowserDialogVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -93,6 +103,27 @@ fun InteractionPage(
|
|||||||
initialFilterDialogVisible = true
|
initialFilterDialogVisible = true
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
Subtitle(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
text = stringResource(R.string.article_list),
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
title = stringResource(R.string.swipe_to_start),
|
||||||
|
desc = swipeToStartAction.desc,
|
||||||
|
onClick = {
|
||||||
|
swipeStartDialogVisible = true
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
title = stringResource(R.string.swipe_to_end),
|
||||||
|
desc = swipeToEndAction.desc,
|
||||||
|
onClick = {
|
||||||
|
swipeEndDialogVisible = true
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
Subtitle(
|
Subtitle(
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
text = stringResource(R.string.external_links),
|
text = stringResource(R.string.external_links),
|
||||||
@ -154,6 +185,37 @@ fun InteractionPage(
|
|||||||
initialFilterDialogVisible = false
|
initialFilterDialogVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RadioDialog(
|
||||||
|
visible = swipeStartDialogVisible,
|
||||||
|
title = stringResource(R.string.swipe_to_start),
|
||||||
|
options = SwipeStartActionPreference.values.map {
|
||||||
|
RadioDialogOption(
|
||||||
|
text = it.desc,
|
||||||
|
selected = it == swipeToStartAction,
|
||||||
|
) {
|
||||||
|
it.put(context, scope)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
swipeStartDialogVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioDialog(
|
||||||
|
visible = swipeEndDialogVisible,
|
||||||
|
title = stringResource(R.string.swipe_to_end),
|
||||||
|
options = SwipeEndActionPreference.values.map {
|
||||||
|
RadioDialogOption(
|
||||||
|
text = it.desc,
|
||||||
|
selected = it == swipeToEndAction,
|
||||||
|
) {
|
||||||
|
it.put(context, scope)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
swipeEndDialogVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
RadioDialog(
|
RadioDialog(
|
||||||
visible = openLinkDialogVisible,
|
visible = openLinkDialogVisible,
|
||||||
title = stringResource(R.string.initial_open_app),
|
title = stringResource(R.string.initial_open_app),
|
||||||
@ -181,7 +243,8 @@ fun InteractionPage(
|
|||||||
text = it.loadLabel(context.packageManager).toString(),
|
text = it.loadLabel(context.packageManager).toString(),
|
||||||
selected = it.activityInfo.packageName == openLinkSpecificBrowser.packageName,
|
selected = it.activityInfo.packageName == openLinkSpecificBrowser.packageName,
|
||||||
) {
|
) {
|
||||||
openLinkSpecificBrowser.copy(packageName = it.activityInfo.packageName).put(context, scope)
|
openLinkSpecificBrowser.copy(packageName = it.activityInfo.packageName)
|
||||||
|
.put(context, scope)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
|
@ -414,4 +414,9 @@
|
|||||||
<string name="submit_bug_report">submit a bug report on GitHub</string>
|
<string name="submit_bug_report">submit a bug report on GitHub</string>
|
||||||
<string name="unexpected_error_msg">The app encountered an unexpected error and had to close.\n\nTo help us identify and fix this issue quickly, you can %1$s with the error stack trace below.</string>
|
<string name="unexpected_error_msg">The app encountered an unexpected error and had to close.\n\nTo help us identify and fix this issue quickly, you can %1$s with the error stack trace below.</string>
|
||||||
<string name="next_article">Next article</string>
|
<string name="next_article">Next article</string>
|
||||||
|
<string name="swipe_to_start">Swipe left</string>
|
||||||
|
<string name="swipe_to_end">Swipe right</string>
|
||||||
|
<string name="none">None</string>
|
||||||
|
<string name="toggle_read">Toggle read</string>
|
||||||
|
<string name="toggle_starred">Toggle starred</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user