diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Direction.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Direction.kt new file mode 100644 index 00000000..5d7f8650 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Direction.kt @@ -0,0 +1,8 @@ +package ua.acclorite.book_story.domain.util + +import androidx.compose.runtime.Immutable + +@Immutable +enum class Direction { + START, NEUTRAL, END +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/core/util/Functions.kt b/app/src/main/java/ua/acclorite/book_story/presentation/core/util/Functions.kt index fc89f4f5..6f12d22d 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/core/util/Functions.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/core/util/Functions.kt @@ -33,4 +33,12 @@ fun Intent.launchActivity( } success?.invoke() +} + +fun Float.calculateProgress(digits: Int): String { + return (this * 100) + .toDouble() + .removeDigits(digits) + .removeTrailingZero() + .dropWhile { it == '-' } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt index b7e51dc5..4e8295fa 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoStatisticSection.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoStatisticSection.kt index 78e911a7..39a59177 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoStatisticSection.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoStatisticSection.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt index e6ebcc57..64017981 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt @@ -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( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt index 209a4aea..cd9cecdf 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt @@ -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 ) ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderTextParagraph.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderTextParagraph.kt index 472ca2ee..3435203c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderTextParagraph.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderTextParagraph.kt @@ -110,6 +110,7 @@ fun LazyItemScope.ReaderTextParagraph( onEvent( ReaderEvent.OnShowHideMenu( fullscreenMode = fullscreenMode, + saveCheckpoint = true, activity = context as ComponentActivity ) ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt index 72ae1645..9d86e90c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt @@ -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)) + } + ) + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt index a4b5b35a..73703d76 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt @@ -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 ) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt index 9929be10..a9b5c924 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt @@ -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, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt index f2f258d7..05d5e006 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt @@ -16,6 +16,7 @@ data class ReaderState( val loading: Boolean = true, val showMenu: Boolean = false, + val checkpoint: Pair = 0 to 0, val lockMenu: Boolean = false, val showSettingsBottomSheet: Boolean = false, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt index 4e8d85d9..f59e30ed 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt @@ -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 ) ) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e8d64fa6..9f26af47 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -594,5 +594,7 @@ GitHub Профіль Тягнути Фільтр + Чекпоінт назад + Чекпоінт вперед \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea3d6d89..acca882d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -613,5 +613,7 @@ GitHub Profile Drag Filter + Checkpoint back + Checkpoint forward \ No newline at end of file