diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt index ab3f3ce65..e6b6b19b2 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt @@ -289,9 +289,16 @@ private fun listItemColors( val defaults = ListItemDefaults.colors() val isMonochrome = LocalAppTheme.current == AppTheme.MONOCHROME val dimColors = read && !isMonochrome + val isListFocused = LocalListFocused.current + + val containerColor = when { + selected && isListFocused -> colorScheme.primaryContainer + selected -> colorScheme.surfaceVariant + else -> defaults.containerColor + } return ListItemDefaults.colors( - containerColor = if (selected) colorScheme.surfaceVariant else defaults.containerColor, + containerColor = containerColor, headlineColor = if (dimColors) defaults.disabledContentColor else defaults.headlineColor, supportingColor = if (dimColors) defaults.disabledContentColor else defaults.supportingTextColor ) diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt index 2e67b6953..f6857c984 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt @@ -29,8 +29,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.foundation.focusable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @@ -38,6 +41,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.capyreader.app.ui.articles.detail.ArticleShortcut +import com.capyreader.app.ui.articles.detail.articleKeyboardHandler import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -192,6 +197,8 @@ fun ArticleScreen( val scrollBehavior = pinnedScrollBehavior() var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) } val focusManager = LocalFocusManager.current + val listFocusRequester = remember { FocusRequester() } + val detailFocusRequester = remember { FocusRequester() } val openUpdatePasswordDialog = { viewModel.dismissUnauthorizedMessage() setUpdatePasswordDialogOpen(true) @@ -480,9 +487,68 @@ fun ArticleScreen( listPane = { val keyboardManager = LocalSoftwareKeyboardController.current val markReadPosition = LocalMarkAllReadButtonPosition.current + val isListFocused = + scaffoldNavigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List + + val currentArticleIndex = remember(article?.id, articles.itemCount) { + if (article == null) -1 + else articles.itemSnapshotList.indexOfFirst { it?.id == article.id } + } + + val onListShortcut = { shortcut: ArticleShortcut -> + when (shortcut) { + ArticleShortcut.NextArticle -> { + val nextIndex = if (currentArticleIndex < 0) 0 else currentArticleIndex + 1 + if (nextIndex < articles.itemCount) { + articles[nextIndex]?.let { setArticle(it.id) } + } + } + ArticleShortcut.PreviousArticle -> { + val prevIndex = currentArticleIndex - 1 + if (prevIndex >= 0) { + articles[prevIndex]?.let { setArticle(it.id) } + } + } + ArticleShortcut.OpenInBrowser -> { + article?.url?.let { url -> + linkOpener.open(url.toString().toUri()) + } + } + ArticleShortcut.ToggleStar -> { + if (article != null) { + viewModel.toggleArticleStar() + } + } + ArticleShortcut.ToggleRead -> { + if (article != null) { + viewModel.toggleArticleRead() + } + } + ArticleShortcut.ToggleFullContent -> { + if (article != null) { + fullContent.fetch() + } + } + ArticleShortcut.FocusDetail -> { + coroutineScope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + detailFocusRequester.requestFocus() + } + } + ArticleShortcut.FocusList -> {} + else -> {} + } + Unit + } Scaffold( modifier = Modifier + .focusRequester(listFocusRequester) + .focusable() + .articleKeyboardHandler( + isDetailPaneFocused = false, + onShortcut = onListShortcut, + ) .nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(object : NestedScrollConnection { override fun onPostScroll( @@ -565,19 +631,23 @@ fun ArticleScreen( }, ) { key(filter, articles.itemCount) { - ArticleList( - articles = articles, - selectedArticleKey = article?.id, - listState = listState, - enableMarkReadOnScroll = enableMarkReadOnScroll, - refreshingAll = viewModel.refreshingAll, - onMarkAllRead = { range -> - onMarkAllRead(range) - }, - onSelect = { articleID -> - selectArticle(articleID) - }, - ) + CompositionLocalProvider( + LocalListFocused provides isListFocused + ) { + ArticleList( + articles = articles, + selectedArticleKey = article?.id, + listState = listState, + enableMarkReadOnScroll = enableMarkReadOnScroll, + refreshingAll = viewModel.refreshingAll, + onMarkAllRead = { range -> + onMarkAllRead(range) + }, + onSelect = { articleID -> + selectArticle(articleID) + }, + ) + } } } } @@ -594,6 +664,9 @@ fun ArticleScreen( CapyPlaceholder() } } else if (article != null) { + val isDetailPaneFocused = + scaffoldNavigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail + ArticleView( article = article, articles = articles, @@ -603,6 +676,20 @@ fun ArticleScreen( onToggleRead = viewModel::toggleArticleRead, onToggleStar = viewModel::toggleArticleStar, enableBackHandler = media == null, + isDetailPaneFocused = isDetailPaneFocused, + onFocusList = { + coroutineScope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) + listFocusRequester.requestFocus() + } + }, + focusRequester = detailFocusRequester, + onFocusDetail = { + coroutineScope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + detailFocusRequester.requestFocus() + } + }, onSelectMedia = { media = it }, onSelectArticle = { articleID -> setArticle(articleID) diff --git a/app/src/main/java/com/capyreader/app/ui/articles/LocalListFocused.kt b/app/src/main/java/com/capyreader/app/ui/articles/LocalListFocused.kt new file mode 100644 index 000000000..c26c8ce23 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/LocalListFocused.kt @@ -0,0 +1,5 @@ +package com.capyreader.app.ui.articles + +import androidx.compose.runtime.compositionLocalOf + +val LocalListFocused = compositionLocalOf { false } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleKeyboardHandler.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleKeyboardHandler.kt new file mode 100644 index 000000000..0d0f39421 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleKeyboardHandler.kt @@ -0,0 +1,41 @@ +package com.capyreader.app.ui.articles.detail + +import android.view.KeyEvent +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.onKeyEvent + +fun Modifier.articleKeyboardHandler( + isDetailPaneFocused: Boolean, + onShortcut: (ArticleShortcut) -> Unit, +): Modifier = onKeyEvent { event -> + if (event.nativeKeyEvent.action != KeyEvent.ACTION_DOWN) { + return@onKeyEvent false + } + + val shortcut = mapKeyToShortcut( + keyCode = event.nativeKeyEvent.keyCode, + isDetailPaneFocused = isDetailPaneFocused, + ) ?: return@onKeyEvent false + + onShortcut(shortcut) + true +} + +private fun mapKeyToShortcut(keyCode: Int, isDetailPaneFocused: Boolean): ArticleShortcut? { + return when (keyCode) { + KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_DPAD_DOWN -> { + if (isDetailPaneFocused) ArticleShortcut.ScrollDown else ArticleShortcut.NextArticle + } + KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_DPAD_UP -> { + if (isDetailPaneFocused) ArticleShortcut.ScrollUp else ArticleShortcut.PreviousArticle + } + KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_DPAD_LEFT -> ArticleShortcut.FocusList + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_DPAD_RIGHT -> ArticleShortcut.FocusDetail + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER -> ArticleShortcut.OpenInBrowser + KeyEvent.KEYCODE_S -> ArticleShortcut.ToggleStar + KeyEvent.KEYCODE_M -> ArticleShortcut.ToggleRead + KeyEvent.KEYCODE_C -> ArticleShortcut.ToggleFullContent + KeyEvent.KEYCODE_ESCAPE -> ArticleShortcut.GoBack + else -> null + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt index d8ff4eb8a..3c2f00841 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt @@ -36,6 +36,7 @@ import kotlin.math.roundToInt fun ArticleReader( article: Article, onSelectMedia: (media: Media) -> Unit, + onScrollStateReady: (ScrollState, maxHeight: Float) -> Unit = { _, _ -> }, ) { val (shareLink, setShareLink) = rememberSaveableShareLink() val linkOpener = LocalLinkOpener.current @@ -62,7 +63,12 @@ fun ArticleReader( ) } } else { - ScrollableWebView(webViewState, article, showImages) + ScrollableWebView( + webViewState = webViewState, + article = article, + showImages = showImages, + onScrollStateReady = onScrollStateReady, + ) } ArticleStyleListener(webView = webViewState.webView) @@ -78,7 +84,12 @@ fun ArticleReader( } @Composable -fun ScrollableWebView(webViewState: WebViewState, article: Article, showImages: Boolean) { +fun ScrollableWebView( + webViewState: WebViewState, + article: Article, + showImages: Boolean, + onScrollStateReady: (ScrollState, maxHeight: Float) -> Unit = { _, _ -> }, +) { var maxHeight by remember { mutableFloatStateOf(0f) } val scrollState = rememberSaveable(article.id, saver = ScrollState.Saver) { ScrollState(initial = 0) @@ -86,6 +97,10 @@ fun ScrollableWebView(webViewState: WebViewState, article: Article, showImages: var lastScrollYPercent by rememberSaveable(article.id) { mutableFloatStateOf(0f) } + LaunchedEffect(scrollState, maxHeight) { + onScrollStateReady(scrollState, maxHeight) + } + CornerTapGestureScroll( maxArticleHeight = maxHeight, scrollState = scrollState, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleShortcut.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleShortcut.kt new file mode 100644 index 000000000..a1cb187b9 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleShortcut.kt @@ -0,0 +1,15 @@ +package com.capyreader.app.ui.articles.detail + +sealed class ArticleShortcut { + data object NextArticle : ArticleShortcut() + data object PreviousArticle : ArticleShortcut() + data object OpenInBrowser : ArticleShortcut() + data object ToggleStar : ArticleShortcut() + data object ToggleRead : ArticleShortcut() + data object ToggleFullContent : ArticleShortcut() + data object GoBack : ArticleShortcut() + data object ScrollDown : ArticleShortcut() + data object ScrollUp : ArticleShortcut() + data object FocusList : ArticleShortcut() + data object FocusDetail : ArticleShortcut() +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt index fcd388737..57b2e504c 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt @@ -21,18 +21,27 @@ import androidx.compose.material3.FlexibleBottomAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.jocmp.capy.common.launchUI import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.foundation.focusable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp @@ -62,6 +71,10 @@ fun ArticleView( onToggleRead: () -> Unit, onToggleStar: () -> Unit, enableBackHandler: Boolean = false, + isDetailPaneFocused: Boolean = true, + focusRequester: FocusRequester = remember { FocusRequester() }, + onFocusList: () -> Unit = {}, + onFocusDetail: () -> Unit = {}, onScrollToArticle: (index: Int) -> Unit, onSelectArticle: (id: String) -> Unit, onSelectMedia: (media: Media) -> Unit, @@ -114,6 +127,41 @@ fun ArticleView( } } + var readerScrollState by remember { mutableStateOf(null) } + var readerMaxHeight by remember { mutableFloatStateOf(0f) } + val coroutineScope = rememberCoroutineScope() + + val onScrollStateReady = { scrollState: ScrollState, maxHeight: Float -> + readerScrollState = scrollState + readerMaxHeight = maxHeight + } + + val scrollReader = { direction: Int -> + readerScrollState?.let { scrollState -> + val scrollAmount = readerMaxHeight * SCROLL_PROPORTION * direction + coroutineScope.launchUI { + scrollState.scrollBy(scrollAmount) + } + } + Unit + } + + val onShortcut = { shortcut: ArticleShortcut -> + when (shortcut) { + ArticleShortcut.NextArticle -> selectNext() + ArticleShortcut.PreviousArticle -> selectPrevious() + ArticleShortcut.OpenInBrowser -> openLink() + ArticleShortcut.ToggleStar -> onToggleStar() + ArticleShortcut.ToggleRead -> onToggleRead() + ArticleShortcut.ToggleFullContent -> onToggleFullContent() + ArticleShortcut.GoBack -> onBackPressed() + ArticleShortcut.ScrollDown -> scrollReader(1) + ArticleShortcut.ScrollUp -> scrollReader(-1) + ArticleShortcut.FocusList -> onFocusList() + ArticleShortcut.FocusDetail -> onFocusDetail() + } + } + val topToolbarPreference = rememberTopToolbarPreference() val bottomScrollBehavior = exitAlwaysScrollBehavior() val enableBottomBar by rememberBottomBarPreference() @@ -129,6 +177,9 @@ fun ArticleView( bottomScrollBehavior = bottomScrollBehavior, enableBottomBar = enableBottomBar, topToolbarPreference = topToolbarPreference, + focusRequester = focusRequester, + isDetailPaneFocused = isDetailPaneFocused, + onShortcut = onShortcut, topBar = { ArticleTopBar( scrollBehavior = topToolbarPreference.scrollBehavior, @@ -179,6 +230,7 @@ fun ArticleView( ArticleReader( article = targetArticle, onSelectMedia = onSelectMedia, + onScrollStateReady = onScrollStateReady, ) } } @@ -205,9 +257,18 @@ private fun ArticleViewScaffold( reader: @Composable () -> Unit, bottomScrollBehavior: BottomAppBarScrollBehavior, topToolbarPreference: ToolbarPreferences, + focusRequester: FocusRequester, + isDetailPaneFocused: Boolean, + onShortcut: (ArticleShortcut) -> Unit, ) { Scaffold( modifier = Modifier + .focusRequester(focusRequester) + .focusable() + .articleKeyboardHandler( + isDetailPaneFocused = isDetailPaneFocused, + onShortcut = onShortcut, + ) .nestedScroll(bottomScrollBehavior.nestedScrollConnection) .nestedScroll(topToolbarPreference.scrollBehavior.nestedScrollConnection), topBar = { @@ -368,3 +429,5 @@ private data class SwipePreferences( val topSwipe: ArticleVerticalSwipe, val bottomSwipe: ArticleVerticalSwipe, ) + +private const val SCROLL_PROPORTION = 0.05f