merge(feat): checkpoints into develop

This commit is contained in:
Acclorite 2025-07-05 16:35:08 +03:00
commit 460724425a
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
9 changed files with 273 additions and 191 deletions

View file

@ -9,6 +9,7 @@ package ua.acclorite.book_story.presentation.reader
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.reader.ReaderText.Chapter
import ua.acclorite.book_story.presentation.reader.model.Checkpoint
@Immutable
sealed class ReaderEvent {
@ -39,7 +40,9 @@ sealed class ReaderEvent {
val progress: Float
) : ReaderEvent()
data object OnRestoreCheckpoint : ReaderEvent()
data class OnRestoreCheckpoint(
val checkpoint: Checkpoint
) : ReaderEvent()
data class OnLeave(
val activity: ComponentActivity,

View file

@ -143,7 +143,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnMenuVisibility -> {
launch {
launch(Dispatchers.Default) {
if (_state.value.lockMenu) return@launch
yield()
@ -152,21 +152,31 @@ class ReaderModel @Inject constructor(
show = event.show || !event.fullscreenMode,
activity = event.activity
)
val checkpoints = _state.value.checkpoints.toMutableList()
if (event.saveCheckpoint && event.show) {
checkpoints.removeIf {
it.index == _state.value.listState.firstVisibleItemIndex
}
checkpoints.add(
Checkpoint(
_state.value.listState.firstVisibleItemIndex,
_state.value.listState.firstVisibleItemScrollOffset
)
)
}
_state.update {
it.copy(
showMenu = event.show,
checkpoint = _state.value.listState.run {
if (!event.show || !event.saveCheckpoint) return@run it.checkpoint
Checkpoint(firstVisibleItemIndex, firstVisibleItemScrollOffset)
}
checkpoints = checkpoints
)
}
}
}
is ReaderEvent.OnChangeProgress -> {
launch(Dispatchers.IO) {
launch(Dispatchers.Default) {
_state.update {
it.copy(
book = it.book.copy(
@ -185,7 +195,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnScrollToChapter -> {
launch {
launch(Dispatchers.Default) {
_state.value.apply {
val chapterIndex = text.indexOf(event.chapter).takeIf { it != -1 }
if (chapterIndex == null) {
@ -207,40 +217,52 @@ class ReaderModel @Inject constructor(
is ReaderEvent.OnScroll -> {
scrollJob?.cancel()
scrollJob = launch {
scrollJob = launch(Dispatchers.Main) {
delay(300)
yield()
val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
_state.value.listState.requestScrollToItem(scrollTo)
updateChapter(scrollTo)
try {
_state.value.listState.requestScrollToItem(scrollTo)
} catch (_: Exception) {
}
}
}
is ReaderEvent.OnRestoreCheckpoint -> {
launch {
_state.value.apply {
listState.requestScrollToItem(
checkpoint.index,
checkpoint.offset
)
launch(Dispatchers.Default) {
_state.update {
val checkpoints = it.checkpoints.toMutableList()
if (checkpoints.size > 1) checkpoints.remove(event.checkpoint)
updateChapter(checkpoint.index)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(checkpoint.index),
firstVisibleItemIndex = checkpoint.index,
firstVisibleItemOffset = checkpoint.offset,
)
it.copy(
checkpoints = checkpoints
)
}
try {
_state.value.listState.requestScrollToItem(
event.checkpoint.index,
event.checkpoint.offset
)
} catch (_: Exception) {
}
updateChapter(event.checkpoint.index)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(event.checkpoint.index),
firstVisibleItemIndex = event.checkpoint.index,
firstVisibleItemOffset = event.checkpoint.offset,
)
)
}
}
is ReaderEvent.OnLeave -> {
launch {
yield()
launch(Dispatchers.Main) {
_state.update {
it.copy(
lockMenu = true
@ -282,7 +304,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnOpenTranslator -> {
launch {
launch(Dispatchers.Default) {
val translatorIntent = Intent()
val browserIntent = Intent()
@ -324,7 +346,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnOpenShareApp -> {
launch {
launch(Dispatchers.Default) {
val shareIntent = Intent()
shareIntent.action = Intent.ACTION_SEND
@ -356,7 +378,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnOpenWebBrowser -> {
launch {
launch(Dispatchers.Default) {
val browserIntent = Intent()
browserIntent.action = Intent.ACTION_WEB_SEARCH
@ -382,7 +404,7 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnOpenDictionary -> {
launch {
launch(Dispatchers.Default) {
val dictionaryIntent = Intent()
val browserIntent = Intent()

View file

@ -416,7 +416,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
currentChapterProgress = state.value.currentChapterProgress,
isLoading = state.value.isLoading,
errorMessage = state.value.errorMessage,
checkpoint = state.value.checkpoint,
checkpoints = state.value.checkpoints,
showMenu = state.value.showMenu,
lockMenu = state.value.lockMenu,
contentPadding = contentPadding,

View file

@ -29,7 +29,7 @@ data class ReaderState(
val isLoading: Boolean = true,
val showMenu: Boolean = false,
val checkpoint: Checkpoint = Checkpoint(0, 0),
val checkpoints: List<Checkpoint> = emptyList(),
val lockMenu: Boolean = false,
val bottomSheet: BottomSheet? = null,

View file

@ -6,27 +6,37 @@
package ua.acclorite.book_story.ui.reader
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
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.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.lazy.LazyListState
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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 androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
@ -48,34 +58,41 @@ fun ReaderBottomBar(
text: List<ReaderText>,
listState: LazyListState,
lockMenu: Boolean,
checkpoint: Checkpoint,
checkpoints: List<Checkpoint>,
bottomBarPadding: Dp,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) {
val firstVisibleItemIndex = remember {
val currentIndex by remember {
derivedStateOf {
listState.firstVisibleItemIndex
}
}
val arrowDirection = remember(checkpoint.index, firstVisibleItemIndex) {
derivedStateOf {
val checkpointIndex = checkpoint.index
val index = firstVisibleItemIndex.value
when {
checkpointIndex > index -> Direction.END
checkpointIndex < index -> Direction.START
else -> Direction.NEUTRAL
val checkpointsProgress = rememberCheckpointsProgress(
checkpoints = checkpoints,
text = text,
currentIndex = currentIndex
)
val currentCheckpoint = rememberCurrentCheckpoint(
checkpoints = checkpoints,
currentIndex = currentIndex
)
val checkpointDirection = rememberCheckpointDirection(
currentCheckpoint = currentCheckpoint,
currentIndex = currentIndex
)
val restoreCheckpoint = remember(currentCheckpoint) {
{
if (currentCheckpoint != null) {
restoreCheckpoint(
ReaderEvent.OnRestoreCheckpoint(
currentCheckpoint
)
)
}
}
}
val checkpointProgress = remember(checkpoint.index, text.lastIndex) {
derivedStateOf {
(checkpoint.index / text.lastIndex.toFloat()) * 0.987f
}
}
Column(
Modifier
@ -83,12 +100,11 @@ fun ReaderBottomBar(
.background(MaterialTheme.colorScheme.readerBarsColor)
.noRippleClickable(onClick = {})
.navigationBarsPadding()
.padding(horizontal = 18.dp),
.padding(horizontal = 18.dp)
.padding(top = 16.dp, bottom = 8.dp + bottomBarPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Spacer(Modifier.height(16.dp))
StyledText(
text = progress,
style = MaterialTheme.typography.titleLarge.copy(
@ -96,13 +112,10 @@ fun ReaderBottomBar(
)
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
HorizontalExpandingTransition(
visible = arrowDirection.value == Direction.START,
startDirection = true
) {
ReaderBottomBarCheckpoints(
checkpointDirection = checkpointDirection,
startArrow = {
IconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = R.string.checkpoint_back_content_desc,
@ -110,31 +123,10 @@ fun ReaderBottomBar(
color = MaterialTheme.colorScheme.secondary,
disableOnClick = false
) {
restoreCheckpoint(ReaderEvent.OnRestoreCheckpoint)
restoreCheckpoint()
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
ReaderBottomBarSlider(
book = book,
lockMenu = lockMenu,
listState = listState,
scroll = scroll,
changeProgress = changeProgress
)
if (arrowDirection.value != Direction.NEUTRAL) {
ReaderBottomBarSliderIndicator(progress = checkpointProgress.value)
}
}
HorizontalExpandingTransition(
visible = arrowDirection.value == Direction.END,
startDirection = false
) {
},
endArrow = {
IconButton(
icon = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = R.string.checkpoint_forward_content_desc,
@ -142,11 +134,160 @@ fun ReaderBottomBar(
color = MaterialTheme.colorScheme.secondary,
disableOnClick = false
) {
restoreCheckpoint(ReaderEvent.OnRestoreCheckpoint)
restoreCheckpoint()
}
},
slider = {
ReaderBottomBarSlider(
book = book,
lockMenu = lockMenu,
listState = listState,
scroll = scroll,
changeProgress = changeProgress
)
},
indicator = {
ReaderBottomBarCheckpointsIndicator(
checkpointsProgress = checkpointsProgress
)
}
)
}
}
}
Spacer(Modifier.height(8.dp + bottomBarPadding))
@Composable
private fun rememberCheckpointsProgress(
checkpoints: List<Checkpoint>,
text: List<ReaderText>,
currentIndex: Int
): List<Float> {
return remember(checkpoints, text, currentIndex) {
if (text.isEmpty()) return@remember emptyList()
val progressScale = 0.987f
checkpoints.mapNotNull { checkpoint ->
if (checkpoint.index == currentIndex) return@mapNotNull null
(checkpoint.index / text.lastIndex.toFloat()) * progressScale
}
}
}
@Composable
private fun rememberCurrentCheckpoint(
checkpoints: List<Checkpoint>,
currentIndex: Int
): Checkpoint? {
return remember(checkpoints, currentIndex) {
checkpoints.lastOrNull { checkpoint ->
when {
checkpoint.index > currentIndex -> true
checkpoint.index < currentIndex -> true
else -> false
}
}
}
}
@Composable
private fun rememberCheckpointDirection(
currentCheckpoint: Checkpoint?,
currentIndex: Int
): Direction {
return remember(currentCheckpoint, currentIndex) {
when {
currentCheckpoint == null -> Direction.NEUTRAL
currentCheckpoint.index > currentIndex -> Direction.END
currentCheckpoint.index < currentIndex -> Direction.START
else -> Direction.NEUTRAL
}
}
}
@Composable
private fun RowScope.ReaderBottomBarCheckpoints(
checkpointDirection: Direction,
startArrow: @Composable () -> Unit,
endArrow: @Composable () -> Unit,
slider: @Composable () -> Unit,
indicator: @Composable () -> Unit
) {
HorizontalExpandingTransition(
visible = checkpointDirection == Direction.START,
startDirection = true
) {
startArrow()
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
slider()
indicator()
}
HorizontalExpandingTransition(
visible = checkpointDirection == Direction.END,
startDirection = false
) {
endArrow()
}
}
@Composable
private fun ReaderBottomBarSlider(
book: Book,
lockMenu: Boolean,
listState: LazyListState,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isDragging = interactionSource.collectIsDraggedAsState()
val animatedProgress = animateFloatAsState(book.progress)
val progress = remember(isDragging.value, book.progress, animatedProgress.value) {
if (isDragging.value) book.progress
else animatedProgress.value
}
Slider(
value = progress,
enabled = !lockMenu,
onValueChange = {
if (listState.layoutInfo.totalItemsCount > 0) {
scroll(ReaderEvent.OnScroll(it))
changeProgress(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0
)
)
}
},
interactionSource = interactionSource,
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),
)
)
}
@Composable
private fun ReaderBottomBarCheckpointsIndicator(checkpointsProgress: List<Float>) {
checkpointsProgress.forEach { checkpoint ->
Row {
Spacer(modifier = Modifier.fillMaxWidth(checkpoint))
Box(
Modifier
.width(4.dp)
.height(16.dp)
.clip(RoundedCornerShape(0.5.dp))
.background(MaterialTheme.colorScheme.onPrimary.copy(0.7f))
)
}
}
}

View file

@ -1,47 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.ui.reader
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.presentation.reader.ReaderEvent
@Composable
fun ReaderBottomBarSlider(
book: Book,
lockMenu: Boolean,
listState: LazyListState,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) {
Slider(
value = book.progress,
enabled = !lockMenu,
onValueChange = {
if (listState.layoutInfo.totalItemsCount > 0) {
scroll(ReaderEvent.OnScroll(it))
changeProgress(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0
)
)
}
},
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),
)
)
}

View file

@ -1,37 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.ui.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
fun ReaderBottomBarSliderIndicator(progress: Float) {
Row(Modifier.fillMaxWidth()) {
Spacer(
modifier = Modifier.fillMaxWidth(progress)
)
Box(
Modifier
.width(4.dp)
.height(16.dp)
.clip(RoundedCornerShape(0.5.dp))
.background(MaterialTheme.colorScheme.onPrimary.copy(0.6f))
)
}
}

View file

@ -49,7 +49,7 @@ fun ReaderContent(
currentChapterProgress: Float,
isLoading: Boolean,
errorMessage: UIText?,
checkpoint: Checkpoint,
checkpoints: List<Checkpoint>,
showMenu: Boolean,
lockMenu: Boolean,
contentPadding: PaddingValues,
@ -128,7 +128,7 @@ fun ReaderContent(
perceptionExpanderThickness = perceptionExpanderThickness,
currentChapterProgress = currentChapterProgress,
isLoading = isLoading,
checkpoint = checkpoint,
checkpoints = checkpoints,
showMenu = showMenu,
lockMenu = lockMenu,
contentPadding = contentPadding,

View file

@ -53,7 +53,7 @@ fun ReaderScaffold(
perceptionExpanderThickness: Dp,
currentChapterProgress: Float,
isLoading: Boolean,
checkpoint: Checkpoint,
checkpoints: List<Checkpoint>,
showMenu: Boolean,
lockMenu: Boolean,
contentPadding: PaddingValues,
@ -150,7 +150,7 @@ fun ReaderScaffold(
text = text,
listState = listState,
lockMenu = lockMenu,
checkpoint = checkpoint,
checkpoints = checkpoints,
bottomBarPadding = bottomBarPadding,
restoreCheckpoint = restoreCheckpoint,
scroll = scroll,