🚀 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:
parent
cf8632c48b
commit
e212733300
14 changed files with 212 additions and 61 deletions
|
|
@ -0,0 +1,8 @@
|
|||
package ua.acclorite.book_story.domain.util
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
enum class Direction {
|
||||
START, NEUTRAL, END
|
||||
}
|
||||
|
|
@ -33,4 +33,12 @@ fun Intent.launchActivity(
|
|||
}
|
||||
|
||||
success?.invoke()
|
||||
}
|
||||
|
||||
fun Float.calculateProgress(digits: Int): String {
|
||||
return (this * 100)
|
||||
.toDouble()
|
||||
.removeDigits(digits)
|
||||
.removeTrailingZero()
|
||||
.dropWhile { it == '-' }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ fun LazyItemScope.ReaderTextParagraph(
|
|||
onEvent(
|
||||
ReaderEvent.OnShowHideMenu(
|
||||
fullscreenMode = fullscreenMode,
|
||||
saveCheckpoint = true,
|
||||
activity = context as ComponentActivity
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue