From 8c11757be44e3401476ecc13b22fe42c185a8af3 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 11 Feb 2024 19:35:19 +0800 Subject: [PATCH] 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 --- .../infrastructure/preference/Preference.kt | 2 + .../infrastructure/preference/Settings.kt | 6 + .../preference/SwipeActionPreference.kt | 98 ++++ .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 12 + .../reader/ui/page/home/flow/ArticleItem.kt | 321 +++++++++--- .../reader/ui/page/home/flow/ArticleList.kt | 43 +- .../ash/reader/ui/page/home/flow/FlowPage.kt | 54 +- .../reader/ui/page/home/flow/FlowViewModel.kt | 25 +- .../ui/page/home/flow/SwipeToDismissBox.kt | 471 ++++++++++++++++++ .../settings/interaction/InteractionPage.kt | 67 ++- app/src/main/res/values/strings.xml | 5 + 11 files changed, 998 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/SwipeActionPreference.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/home/flow/SwipeToDismissBox.kt diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt index 371062920..8f062570e 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt @@ -76,6 +76,8 @@ fun Preferences.toSettings(): Settings { // Interaction initialPage = InitialPagePreference.fromPreferences(this), initialFilter = InitialFilterPreference.fromPreferences(this), + swipeStartAction = SwipeStartActionPreference.fromPreferences(this), + swipeEndAction = SwipeEndActionPreference.fromPreferences(this), openLink = OpenLinkPreference.fromPreferences(this), openLinkSpecificBrowser = OpenLinkSpecificBrowserPreference.fromPreferences(this), diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt index 6dfeb5058..39adcf552 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt @@ -75,6 +75,8 @@ data class Settings( // Interaction val initialPage: InitialPagePreference = InitialPagePreference.default, val initialFilter: InitialFilterPreference = InitialFilterPreference.default, + val swipeStartAction: SwipeStartActionPreference = SwipeStartActionPreference.default, + val swipeEndAction: SwipeEndActionPreference = SwipeEndActionPreference.default, val openLink: OpenLinkPreference = OpenLinkPreference.default, val openLinkSpecificBrowser: OpenLinkSpecificBrowserPreference = OpenLinkSpecificBrowserPreference.default, @@ -177,6 +179,8 @@ val LocalReadingImageMaximize = val LocalInitialPage = compositionLocalOf { InitialPagePreference.default } val LocalInitialFilter = compositionLocalOf { InitialFilterPreference.default } +val LocalArticleListSwipeEndAction = compositionLocalOf { SwipeEndActionPreference.default } +val LocalArticleListSwipeStartAction = compositionLocalOf { SwipeStartActionPreference.default } val LocalOpenLink = compositionLocalOf { OpenLinkPreference.default } val LocalOpenLinkSpecificBrowser = @@ -263,6 +267,8 @@ fun SettingsProvider( // Interaction LocalInitialPage provides settings.initialPage, LocalInitialFilter provides settings.initialFilter, + LocalArticleListSwipeStartAction provides settings.swipeStartAction, + LocalArticleListSwipeEndAction provides settings.swipeEndAction, LocalOpenLink provides settings.openLink, LocalOpenLinkSpecificBrowser provides settings.openLinkSpecificBrowser, diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/SwipeActionPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/SwipeActionPreference.kt new file mode 100644 index 000000000..216b0c4d1 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/SwipeActionPreference.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 1b69ee433..6a5ceb248 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -404,6 +404,18 @@ sealed class DataStoreKeys { get() = intPreferencesKey("initialFilter") } + data object SwipeStartAction : DataStoreKeys() { + + override val key: Preferences.Key + get() = intPreferencesKey("swipeStartAction") + } + + data object SwipeEndAction : DataStoreKeys() { + + override val key: Preferences.Key + get() = intPreferencesKey("swipeEndAction") + } + object OpenLink : DataStoreKeys() { override val key: Preferences.Key diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt index cf1287fcb..6b5102246 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt @@ -1,21 +1,51 @@ 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.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.layout.Arrangement +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.outlined.Circle +import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material.icons.rounded.CheckCircleOutline import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind 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.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -24,7 +54,18 @@ import coil.size.Precision import coil.size.Scale import me.ash.reader.R import me.ash.reader.domain.model.article.ArticleWithFeed -import me.ash.reader.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.base.RYAsyncImage import me.ash.reader.ui.component.base.SIZE_1000 @@ -50,19 +91,17 @@ fun ArticleItem( .clip(Shape20) .clickable { onClick(articleWithFeed) } .padding(horizontal = 12.dp, vertical = 12.dp) - .alpha( - articleWithFeed.article.run { - when (articleListReadIndicator) { - FlowArticleReadIndicatorPreference.AllRead -> { - if (isUnread) 1f else 0.5f - } - - FlowArticleReadIndicatorPreference.ExcludingStarred -> { - if (isUnread || isStarred) 1f else 0.5f - } + .alpha(articleWithFeed.article.run { + when (articleListReadIndicator) { + FlowArticleReadIndicatorPreference.AllRead -> { + if (isUnread) 1f else 0.5f + } + + FlowArticleReadIndicatorPreference.ExcludingStarred -> { + if (isUnread || isStarred) 1f else 0.5f } } - ), + }), ) { // Top Row( @@ -170,70 +209,212 @@ fun ArticleItem( } } -@ExperimentalMaterialApi +private const val PositionalThresholdFraction = 0.15f + +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SwipeableArticleItem( articleWithFeed: ArticleWithFeed, isFilterUnread: Boolean, articleListTonalElevation: Int, onClick: (ArticleWithFeed) -> Unit = {}, - onSwipeOut: (ArticleWithFeed) -> Unit = {}, + isScrollInProgress: () -> Boolean = { false }, + onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null, + onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null, ) { - var isArticleVisible by remember { mutableStateOf(true) } - val dismissState = - rememberDismissState(initialValue = DismissValue.Default, confirmStateChange = { - if (it == DismissValue.DismissedToEnd) { - isArticleVisible = !isFilterUnread - onSwipeOut(articleWithFeed) + val swipeToStartAction = LocalArticleListSwipeStartAction.current + val swipeToEndAction = LocalArticleListSwipeEndAction.current + + val density = LocalDensity.current + val confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { + when (it) { + SwipeToDismissBoxValue.StartToEnd -> { + onSwipeStartToEnd?.invoke(articleWithFeed) + swipeToEndAction == SwipeEndActionPreference.ToggleRead && isFilterUnread } - 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, - ) - } - } - } - }, - /**** Dismiss Content */ - dismissContent = { - Box( - modifier = Modifier - .fillMaxSize() - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation( - articleListTonalElevation.dp - ) onDark MaterialTheme.colorScheme.surface - ) - ) { - ArticleItem(articleWithFeed, onClick) - } - }, - /*** Set Direction to dismiss */ - directions = setOf(DismissDirection.StartToEnd), + 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 = 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 */ + content = { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation( + articleListTonalElevation.dp + ) onDark MaterialTheme.colorScheme.surface + ) + ) { + ArticleItem(articleWithFeed, onClick) + } + }, + /*** Set Direction to dismiss */ + 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) + ) + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt index d2a28c3f8..871922bb1 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems @@ -12,37 +11,41 @@ import me.ash.reader.domain.model.article.ArticleFlowItem import me.ash.reader.domain.model.article.ArticleWithFeed @Suppress("FunctionName") -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalFoundationApi::class) fun LazyListScope.ArticleList( pagingItems: LazyPagingItems, isFilterUnread: Boolean, isShowFeedIcon: Boolean, isShowStickyHeader: Boolean, articleListTonalElevation: Int, + isScrollInProgress: () -> Boolean = { false }, onClick: (ArticleWithFeed) -> Unit = {}, - onSwipeOut: (ArticleWithFeed) -> Unit = {} + onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null, + onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null, ) { for (index in 0 until pagingItems.itemCount) { when (val item = pagingItems.peek(index)) { is ArticleFlowItem.Article -> { item(key = item.articleWithFeed.article.id) { - if (item.articleWithFeed.article.isUnread) { - SwipeableArticleItem( - articleWithFeed = item.articleWithFeed, - isFilterUnread = isFilterUnread, - articleListTonalElevation = articleListTonalElevation, - onClick = { onClick(it) }, - onSwipeOut = { onSwipeOut(it) } - ) - } else { - // Currently we don't have swipe left to mark as unread, - // so [SwipeableArticleItem] is not necessary for read articles. - ArticleItem( - articleWithFeed = (pagingItems[index] as ArticleFlowItem.Article).articleWithFeed, - ) { - onClick(it) - } - } +// if (item.articleWithFeed.article.isUnread) { + SwipeableArticleItem( + articleWithFeed = item.articleWithFeed, + isFilterUnread = isFilterUnread, + articleListTonalElevation = articleListTonalElevation, + onClick = { onClick(it) }, + isScrollInProgress = isScrollInProgress, + onSwipeStartToEnd = onSwipeStartToEnd, + onSwipeEndToStart = onSwipeEndToStart + ) + /* } else { + // Currently we don't have swipe left to mark as unread, + // so [SwipeableArticleItem] is not necessary for read articles. + ArticleItem( + articleWithFeed = (pagingItems[index] as ArticleFlowItem.Article).articleWithFeed, + ) { + onClick(it) + } + }*/ } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt index 9c37b13d1..10a210a33 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt @@ -23,7 +23,7 @@ import androidx.work.WorkInfo import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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.* @@ -52,6 +52,8 @@ fun FlowPage( val filterBarFilled = LocalFlowFilterBarFilled.current val filterBarPadding = LocalFlowFilterBarPadding.current val filterBarTonalElevation = LocalFlowFilterBarTonalElevation.current + val swipeToStartAction = LocalArticleListSwipeStartAction.current + val swipeToEndAction = LocalArticleListSwipeEndAction.current val homeUiState = homeViewModel.homeUiState.collectAsStateValue() val flowUiState = flowViewModel.flowUiState.collectAsStateValue() @@ -71,6 +73,37 @@ fun FlowPage( 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) { snapshotFlow { onSearch }.collect { if (it) { @@ -192,11 +225,12 @@ fun FlowPage( }, ) { markAsRead = false - flowViewModel.markAsRead( + flowViewModel.updateReadStatus( groupId = filterUiState.group?.id, feedId = filterUiState.feed?.id, articleId = null, conditions = it, + isUnread = false ) } RYExtensibleVisibility(visible = onSearch) { @@ -238,20 +272,16 @@ fun FlowPage( isShowFeedIcon = articleListFeedIcon.value, isShowStickyHeader = articleListDateStickyHeader.value, articleListTonalElevation = articleListTonalElevation.value, - onClick = { + isScrollInProgress = { listState.isScrollInProgress }, + onClick = { onSearch = false navController.navigate("${RouteName.READING}/${it.article.id}") { launchSingleTop = true } - } - ) { - flowViewModel.markAsRead( - groupId = null, - feedId = null, - articleId = it.article.id, - MarkAsReadConditions.All - ) - } + }, + onSwipeStartToEnd = onSwipeStartToEnd, + onSwipeEndToStart = onSwipeEndToStart + ) item { Spacer(modifier = Modifier.height(128.dp)) Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt index ac6309a71..ebda100b0 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel 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 @@ -33,22 +34,42 @@ class FlowViewModel @Inject constructor( } } - fun markAsRead( + fun updateReadStatus( groupId: String?, feedId: String?, articleId: String?, conditions: MarkAsReadConditions, + isUnread: Boolean, + withDelay: Long = 0, ) { applicationScope.launch(ioDispatcher) { + delay(withDelay) rssService.get().markAsRead( groupId = groupId, feedId = feedId, articleId = articleId, 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( diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/SwipeToDismissBox.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/SwipeToDismissBox.kt new file mode 100644 index 000000000..cd3ec581f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/SwipeToDismissBox.kt @@ -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 = 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, + density: Density + ) = Saver( + 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 = 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() { + + 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) + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt index 2be4887d0..991669e34 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons 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.runtime.Composable import androidx.compose.runtime.getValue @@ -24,11 +26,15 @@ import androidx.navigation.NavHostController import me.ash.reader.R import me.ash.reader.infrastructure.preference.InitialFilterPreference 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.LocalInitialPage import me.ash.reader.infrastructure.preference.LocalOpenLink import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser 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.FeedbackIconButton import me.ash.reader.ui.component.base.RYScaffold @@ -46,6 +52,8 @@ fun InteractionPage( val context = LocalContext.current val initialPage = LocalInitialPage.current val initialFilter = LocalInitialFilter.current + val swipeToStartAction = LocalArticleListSwipeStartAction.current + val swipeToEndAction = LocalArticleListSwipeEndAction.current val openLink = LocalOpenLink.current val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current val scope = rememberCoroutineScope() @@ -54,6 +62,8 @@ fun InteractionPage( } var initialPageDialogVisible 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 openLinkSpecificBrowserDialogVisible by remember { mutableStateOf(false) } @@ -93,6 +103,27 @@ fun InteractionPage( 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( modifier = Modifier.padding(horizontal = 24.dp), text = stringResource(R.string.external_links), @@ -154,6 +185,37 @@ fun InteractionPage( 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( visible = openLinkDialogVisible, title = stringResource(R.string.initial_open_app), @@ -174,14 +236,15 @@ fun InteractionPage( } RadioDialog( - visible = openLinkSpecificBrowserDialogVisible , + visible = openLinkSpecificBrowserDialogVisible, title = stringResource(R.string.open_link_specific_browser), options = browserList.map { RadioDialogOption( text = it.loadLabel(context.packageManager).toString(), 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 = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90d1f5ac3..a473ce3de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,4 +414,9 @@ submit a bug report on GitHub 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. Next article + Swipe left + Swipe right + None + Toggle read + Toggle starred