🚀 Accident scroll prevention

* Now every time you bring bars, a new checkpoint saves
* Checkpoint restores exact position
* Visual indication of checkpoint position on slider track
* Arrow in the direction of checkpoint when changed position

Resolves: #61
This commit is contained in:
Acclorite 2024-09-10 19:32:30 +03:00
parent cf8632c48b
commit e212733300
14 changed files with 212 additions and 61 deletions

View file

@ -0,0 +1,8 @@
package ua.acclorite.book_story.domain.util
import androidx.compose.runtime.Immutable
@Immutable
enum class Direction {
START, NEUTRAL, END
}

View file

@ -34,3 +34,11 @@ fun Intent.launchActivity(
success?.invoke()
}
fun Float.calculateProgress(digits: Int): String {
return (this * 100)
.toDouble()
.removeDigits(digits)
.removeTrailingZero()
.dropWhile { it == '-' }
}

View file

@ -169,7 +169,10 @@ class MainViewModel @Inject constructor(
is MainEvent.OnChangeParagraphIndentation -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.PARAGRAPH_INDENTATION, event.indentation)
setDatastore.execute(
DataStoreConstants.PARAGRAPH_INDENTATION,
event.indentation
)
updateStateWithSavedHandle {
it.copy(
paragraphIndentation = event.indentation

View file

@ -9,6 +9,8 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
@ -17,8 +19,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.LocalBookInfoViewModel
import ua.acclorite.book_story.presentation.core.util.removeDigits
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
import ua.acclorite.book_story.presentation.core.util.calculateProgress
/**
* Statistic section.
@ -27,13 +28,10 @@ import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
fun BookInfoStatisticSection() {
val state = LocalBookInfoViewModel.current.state
val progress = remember(state.value.book) {
"${
(state.value.book.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%"
val progress by remember {
derivedStateOf {
"${state.value.book.progress.calculateProgress(1)}%"
}
}
val description = stringResource(
if (state.value.book.progress == 1f) R.string.read_done

View file

@ -35,8 +35,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.util.Selected
import ua.acclorite.book_story.presentation.core.components.CustomCoverImage
import ua.acclorite.book_story.presentation.core.util.removeDigits
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
import ua.acclorite.book_story.presentation.core.util.calculateProgress
/**
* Library list element item.
@ -69,13 +68,8 @@ fun LibraryBookItem(
else onSurfaceColor
}
val progress = rememberSaveable(book.first) {
"${
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%"
val progress = rememberSaveable(book.first.progress) {
"${book.first.progress.calculateProgress(1)}%"
}
Column(

View file

@ -128,6 +128,7 @@ fun ReaderScreenRoot(screen: Screen.Reader) {
onEvent(
ReaderEvent.OnShowHideMenu(
fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = true,
activity = context
)
)
@ -138,6 +139,7 @@ fun ReaderScreenRoot(screen: Screen.Reader) {
ReaderEvent.OnShowHideMenu(
show = state.value.showMenu,
fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = false,
activity = context
)
)
@ -192,15 +194,16 @@ private fun ReaderScreen(lazyListState: LazyListState) {
source: NestedScrollSource
): Offset {
consumed.y.let { velocity ->
if(velocity in -70f..70f) return@let
if(!state.value.showMenu) return@let
if(state.value.lockMenu) return@let
if(!mainState.value.hideBarsOnFastScroll) return@let
if (velocity in -70f..70f) return@let
if (!state.value.showMenu) return@let
if (state.value.lockMenu) return@let
if (!mainState.value.hideBarsOnFastScroll) return@let
onEvent(
ReaderEvent.OnShowHideMenu(
show = false,
fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = false,
activity = context
)
)
@ -414,6 +417,7 @@ private fun ReaderScreen(lazyListState: LazyListState) {
onEvent(
ReaderEvent.OnShowHideMenu(
fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = true,
activity = context
)
)

View file

@ -110,6 +110,7 @@ fun LazyItemScope.ReaderTextParagraph(
onEvent(
ReaderEvent.OnShowHideMenu(
fullscreenMode = fullscreenMode,
saveCheckpoint = true,
activity = context as ComponentActivity
)
)

View file

@ -1,14 +1,28 @@
package ua.acclorite.book_story.presentation.screens.reader.components.app_bar
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
@ -19,19 +33,24 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.Direction
import ua.acclorite.book_story.presentation.core.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.core.components.CustomIconButton
import ua.acclorite.book_story.presentation.core.components.LocalHistoryViewModel
import ua.acclorite.book_story.presentation.core.components.LocalLibraryViewModel
import ua.acclorite.book_story.presentation.core.components.LocalReaderViewModel
import ua.acclorite.book_story.presentation.core.util.removeDigits
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
import ua.acclorite.book_story.presentation.core.util.calculateProgress
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.ui.Colors
/**
* Reader bottom bar. Has a slider to change progress.
* Reader bottom bar.
* Has a slider to change progress.
*/
@Composable
fun ReaderBottomBar() {
@ -40,13 +59,26 @@ fun ReaderBottomBar() {
val onLibraryEvent = LocalLibraryViewModel.current.onEvent
val onHistoryEvent = LocalHistoryViewModel.current.onEvent
val progress by remember(state.value.book.progress) {
val progress by remember {
derivedStateOf {
(state.value.book.progress * 100)
.toDouble()
.removeDigits(4)
.removeTrailingZero()
.dropWhile { it == '-' } + "%"
"${state.value.book.progress.calculateProgress(4)}%"
}
}
val arrowDirection by remember {
derivedStateOf {
val checkpoint = state.value.checkpoint.first
val index = state.value.listState.firstVisibleItemIndex
when {
checkpoint > index -> Direction.END
checkpoint < index -> Direction.START
else -> Direction.NEUTRAL
}
}
}
val checkpointProgress by remember {
derivedStateOf {
(state.value.checkpoint.first / state.value.text.lastIndex.toFloat()) * 0.987f
}
}
@ -60,7 +92,7 @@ fun ReaderBottomBar() {
onClick = {}
)
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(horizontal = 18.dp)
.padding(top = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
@ -70,33 +102,99 @@ fun ReaderBottomBar() {
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(3.dp))
Slider(
value = state.value.book.progress,
enabled = !state.value.lockMenu,
onValueChange = {
if (state.value.listState.layoutInfo.totalItemsCount > 0) {
onEvent(ReaderEvent.OnScroll(it))
Row(
modifier = Modifier.padding(top = 3.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAnimatedVisibility(
visible = arrowDirection == Direction.START,
enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + slideInHorizontally { -it },
exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + slideOutHorizontally { -it }
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = R.string.checkpoint_back_content_desc,
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.primary,
disableOnClick = false
) {
onEvent(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0,
refreshList = { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
ReaderEvent.OnRestoreCheckpoint { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
}
},
colors = SliderDefaults.colors(
inactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
disabledActiveTrackColor = MaterialTheme.colorScheme.primary,
disabledThumbColor = MaterialTheme.colorScheme.primary,
disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
)
)
Spacer(modifier = Modifier.height(5.dp))
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
Slider(
value = state.value.book.progress,
enabled = !state.value.lockMenu,
onValueChange = {
if (state.value.listState.layoutInfo.totalItemsCount > 0) {
onEvent(ReaderEvent.OnScroll(it))
onEvent(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0,
refreshList = { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
)
}
},
colors = SliderDefaults.colors(
inactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
disabledActiveTrackColor = MaterialTheme.colorScheme.primary,
disabledThumbColor = MaterialTheme.colorScheme.primary,
disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
)
)
if (arrowDirection != Direction.NEUTRAL) {
Row(Modifier.fillMaxWidth()) {
Spacer(
modifier = Modifier.fillMaxWidth(checkpointProgress)
)
Box(
Modifier
.width(4.dp)
.height(16.dp)
.clip(RoundedCornerShape(0.5.dp))
.background(
MaterialTheme.colorScheme.onPrimary.copy(0.6f)
)
)
}
}
}
CustomAnimatedVisibility(
visible = arrowDirection == Direction.END,
enter = expandHorizontally() + fadeIn() + slideInHorizontally { it },
exit = shrinkHorizontally() + fadeOut() + slideOutHorizontally { it }
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = R.string.checkpoint_forward_content_desc,
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.primary,
disableOnClick = false
) {
onEvent(
ReaderEvent.OnRestoreCheckpoint { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
}
}
}
}
}

View file

@ -63,9 +63,10 @@ fun ReaderSettingsBottomSheet() {
)
onEvent(
ReaderEvent.OnShowHideMenu(
pagerState.currentPage != 2,
show = pagerState.currentPage != 2,
fullscreenMode = mainState.value.fullscreen,
context as ComponentActivity
saveCheckpoint = false,
activity = context as ComponentActivity
)
)
}

View file

@ -18,9 +18,14 @@ sealed class ReaderEvent {
data class OnShowHideMenu(
val show: Boolean? = null,
val fullscreenMode: Boolean,
val saveCheckpoint: Boolean,
val activity: ComponentActivity
) : ReaderEvent()
data class OnRestoreCheckpoint(
val refreshList: (Book) -> Unit
) : ReaderEvent()
data class OnGoBack(
val context: ComponentActivity,
val navigate: () -> Unit,

View file

@ -16,6 +16,7 @@ data class ReaderState(
val loading: Boolean = true,
val showMenu: Boolean = false,
val checkpoint: Pair<Int, Int> = 0 to 0,
val lockMenu: Boolean = false,
val showSettingsBottomSheet: Boolean = false,

View file

@ -159,12 +159,37 @@ class ReaderViewModel @Inject constructor(
)
_state.update {
it.copy(
showMenu = shouldShow
showMenu = shouldShow,
checkpoint = _state.value.listState.run {
if (!shouldShow || !event.saveCheckpoint) return@run it.checkpoint
firstVisibleItemIndex to firstVisibleItemScrollOffset
}
)
}
}
}
is ReaderEvent.OnRestoreCheckpoint -> {
launch(Dispatchers.Main) {
_state.value.listState.requestScrollToItem(
_state.value.checkpoint.first,
_state.value.checkpoint.second
)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(_state.value.checkpoint.first),
firstVisibleItemIndex = _state.value.checkpoint.first,
firstVisibleItemOffset = _state.value.checkpoint.second,
refreshList = { book ->
event.refreshList(book)
}
)
)
}
}
is ReaderEvent.OnGoBack -> {
launch {
yield()
@ -419,6 +444,7 @@ class ReaderViewModel @Inject constructor(
ReaderEvent.OnShowHideMenu(
show = false,
fullscreenMode = fullscreenMode,
saveCheckpoint = false,
activity = activity
)
)

View file

@ -594,5 +594,7 @@
<string name="github_profile_content_desc">GitHub Профіль</string>
<string name="drag_content_desc">Тягнути</string>
<string name="filter_content_desc">Фільтр</string>
<string name="checkpoint_back_content_desc">Чекпоінт назад</string>
<string name="checkpoint_forward_content_desc">Чекпоінт вперед</string>
</resources>

View file

@ -613,5 +613,7 @@
<string name="github_profile_content_desc">GitHub Profile</string>
<string name="drag_content_desc">Drag</string>
<string name="filter_content_desc">Filter</string>
<string name="checkpoint_back_content_desc">Checkpoint back</string>
<string name="checkpoint_forward_content_desc">Checkpoint forward</string>
</resources>