🚀 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() 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 -> { is MainEvent.OnChangeParagraphIndentation -> {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.PARAGRAPH_INDENTATION, event.indentation) setDatastore.execute(
DataStoreConstants.PARAGRAPH_INDENTATION,
event.indentation
)
updateStateWithSavedHandle { updateStateWithSavedHandle {
it.copy( it.copy(
paragraphIndentation = event.indentation paragraphIndentation = event.indentation

View file

@ -9,6 +9,8 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
@ -17,8 +19,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.LocalBookInfoViewModel 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.calculateProgress
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
/** /**
* Statistic section. * Statistic section.
@ -27,13 +28,10 @@ import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
fun BookInfoStatisticSection() { fun BookInfoStatisticSection() {
val state = LocalBookInfoViewModel.current.state val state = LocalBookInfoViewModel.current.state
val progress = remember(state.value.book) { val progress by remember {
"${ derivedStateOf {
(state.value.book.progress * 100) "${state.value.book.progress.calculateProgress(1)}%"
.toDouble() }
.removeDigits(1)
.removeTrailingZero()
}%"
} }
val description = stringResource( val description = stringResource(
if (state.value.book.progress == 1f) R.string.read_done 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.model.Book
import ua.acclorite.book_story.domain.util.Selected import ua.acclorite.book_story.domain.util.Selected
import ua.acclorite.book_story.presentation.core.components.CustomCoverImage 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.calculateProgress
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
/** /**
* Library list element item. * Library list element item.
@ -69,13 +68,8 @@ fun LibraryBookItem(
else onSurfaceColor else onSurfaceColor
} }
val progress = rememberSaveable(book.first) { val progress = rememberSaveable(book.first.progress) {
"${ "${book.first.progress.calculateProgress(1)}%"
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%"
} }
Column( Column(

View file

@ -128,6 +128,7 @@ fun ReaderScreenRoot(screen: Screen.Reader) {
onEvent( onEvent(
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
fullscreenMode = mainState.value.fullscreen, fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = true,
activity = context activity = context
) )
) )
@ -138,6 +139,7 @@ fun ReaderScreenRoot(screen: Screen.Reader) {
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
show = state.value.showMenu, show = state.value.showMenu,
fullscreenMode = mainState.value.fullscreen, fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = false,
activity = context activity = context
) )
) )
@ -201,6 +203,7 @@ private fun ReaderScreen(lazyListState: LazyListState) {
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
show = false, show = false,
fullscreenMode = mainState.value.fullscreen, fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = false,
activity = context activity = context
) )
) )
@ -414,6 +417,7 @@ private fun ReaderScreen(lazyListState: LazyListState) {
onEvent( onEvent(
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
fullscreenMode = mainState.value.fullscreen, fullscreenMode = mainState.value.fullscreen,
saveCheckpoint = true,
activity = context activity = context
) )
) )

View file

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

View file

@ -1,14 +1,28 @@
package ua.acclorite.book_story.presentation.screens.reader.components.app_bar 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
@ -19,19 +33,24 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp 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.LocalHistoryViewModel
import ua.acclorite.book_story.presentation.core.components.LocalLibraryViewModel 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.components.LocalReaderViewModel
import ua.acclorite.book_story.presentation.core.util.removeDigits import ua.acclorite.book_story.presentation.core.util.calculateProgress
import ua.acclorite.book_story.presentation.core.util.removeTrailingZero
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent 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.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.ui.Colors 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 @Composable
fun ReaderBottomBar() { fun ReaderBottomBar() {
@ -40,13 +59,26 @@ fun ReaderBottomBar() {
val onLibraryEvent = LocalLibraryViewModel.current.onEvent val onLibraryEvent = LocalLibraryViewModel.current.onEvent
val onHistoryEvent = LocalHistoryViewModel.current.onEvent val onHistoryEvent = LocalHistoryViewModel.current.onEvent
val progress by remember(state.value.book.progress) { val progress by remember {
derivedStateOf { derivedStateOf {
(state.value.book.progress * 100) "${state.value.book.progress.calculateProgress(4)}%"
.toDouble() }
.removeDigits(4) }
.removeTrailingZero() val arrowDirection by remember {
.dropWhile { it == '-' } + "%" 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 = {} onClick = {}
) )
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 24.dp) .padding(horizontal = 18.dp)
.padding(top = 16.dp), .padding(top = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@ -70,7 +102,35 @@ fun ReaderBottomBar() {
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
Spacer(modifier = Modifier.height(3.dp)) 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.OnRestoreCheckpoint { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
Slider( Slider(
value = state.value.book.progress, value = state.value.book.progress,
enabled = !state.value.lockMenu, enabled = !state.value.lockMenu,
@ -97,6 +157,44 @@ fun ReaderBottomBar() {
disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
) )
) )
Spacer(modifier = Modifier.height(5.dp)) 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( onEvent(
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
pagerState.currentPage != 2, show = pagerState.currentPage != 2,
fullscreenMode = mainState.value.fullscreen, 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( data class OnShowHideMenu(
val show: Boolean? = null, val show: Boolean? = null,
val fullscreenMode: Boolean, val fullscreenMode: Boolean,
val saveCheckpoint: Boolean,
val activity: ComponentActivity val activity: ComponentActivity
) : ReaderEvent() ) : ReaderEvent()
data class OnRestoreCheckpoint(
val refreshList: (Book) -> Unit
) : ReaderEvent()
data class OnGoBack( data class OnGoBack(
val context: ComponentActivity, val context: ComponentActivity,
val navigate: () -> Unit, val navigate: () -> Unit,

View file

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

View file

@ -159,12 +159,37 @@ class ReaderViewModel @Inject constructor(
) )
_state.update { _state.update {
it.copy( 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 -> { is ReaderEvent.OnGoBack -> {
launch { launch {
yield() yield()
@ -419,6 +444,7 @@ class ReaderViewModel @Inject constructor(
ReaderEvent.OnShowHideMenu( ReaderEvent.OnShowHideMenu(
show = false, show = false,
fullscreenMode = fullscreenMode, fullscreenMode = fullscreenMode,
saveCheckpoint = false,
activity = activity activity = activity
) )
) )

View file

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

View file

@ -613,5 +613,7 @@
<string name="github_profile_content_desc">GitHub Profile</string> <string name="github_profile_content_desc">GitHub Profile</string>
<string name="drag_content_desc">Drag</string> <string name="drag_content_desc">Drag</string>
<string name="filter_content_desc">Filter</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> </resources>