0.9.2 - Massive scrolling performance improvement

This commit is contained in:
acclorite 2024-03-15 14:21:00 +02:00
parent 6806ac904c
commit 22ed828ee6
83 changed files with 1175 additions and 1024 deletions

View file

@ -15,7 +15,7 @@ android {
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.9.0"
versionName = "0.9.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -139,4 +139,6 @@ dependencies {
implementation("androidx.appcompat:appcompat:latest.release")
implementation("androidx.appcompat:appcompat-resources:latest.release")
implementation("io.coil-kt:coil-compose:2.6.0")
}

View file

@ -35,7 +35,6 @@
<activity
android:name=".Activity"
android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.Start.Splash">

View file

@ -1,15 +1,13 @@
package ua.acclorite.book_story
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.database.CursorWindow
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -28,7 +26,6 @@ import dagger.hilt.android.AndroidEntryPoint
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.components.bottom_navigation_bar.BottomNavigationBar
import ua.acclorite.book_story.presentation.components.custom_navigation_rail.CustomNavigationRail
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.NavigationHost
import ua.acclorite.book_story.presentation.data.Screen
@ -55,7 +52,7 @@ import java.lang.reflect.Field
@SuppressLint("DiscouragedPrivateApi")
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class Activity : ComponentActivity() {
class Activity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
private val libraryViewModel: LibraryViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels()
@ -75,10 +72,7 @@ class Activity : ComponentActivity() {
}
// Initializing all variables
mainViewModel.init(
activity = this,
libraryViewModel,
)
mainViewModel.init(libraryViewModel)
// Splash screen
installSplashScreen().apply {
@ -93,7 +87,6 @@ class Activity : ComponentActivity() {
setContent {
val windowClass = calculateWindowSizeClass(activity = this)
val updating = mainViewModel.updating.collectAsState().value
val theme = mainViewModel.theme.collectAsState().value ?: Theme.BLUE
val darkTheme =
@ -104,157 +97,142 @@ class Activity : ComponentActivity() {
theme = theme,
isDark = darkTheme.isDark()
) {
if (!updating) {
NavigationHost(startScreen = Screen.LIBRARY) {
val currentScreen by this.getCurrentScreen().collectAsState()
NavigationHost(startScreen = Screen.LIBRARY) {
val currentScreen by this.getCurrentScreen().collectAsState()
AnimatedVisibility(
visible = currentScreen == Screen.LIBRARY ||
currentScreen == Screen.HISTORY ||
currentScreen == Screen.BROWSE,
enter = Transitions.BackSlidingTransitionIn,
exit = Transitions.SlidingTransitionOut
AnimatedVisibility(
visible = currentScreen == Screen.LIBRARY ||
currentScreen == Screen.HISTORY ||
currentScreen == Screen.BROWSE,
enter = Transitions.BackSlidingTransitionIn,
exit = Transitions.SlidingTransitionOut
) {
Scaffold(
bottomBar = {
if (!tabletUI) {
BottomNavigationBar(navigator = this@NavigationHost)
}
},
containerColor = MaterialTheme.colorScheme.surface
) {
Scaffold(
bottomBar = {
if (!tabletUI) {
BottomNavigationBar(navigator = this@NavigationHost)
}
},
containerColor = MaterialTheme.colorScheme.surface
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = if (tabletUI) 80.dp else 0.dp,
bottom = it.calculateBottomPadding()
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = if (tabletUI) 80.dp else 0.dp,
bottom = it.calculateBottomPadding()
)
) {
composable(screen = Screen.LIBRARY) {
@Suppress("UNCHECKED_CAST")
LibraryScreen(
viewModel = libraryViewModel,
historyViewModel = historyViewModel,
browseViewModel = browseViewModel,
navigator = this@NavigationHost,
addedBooks = retrieveArgument("added_books") as? List<Book>
?: emptyList()
)
}
composable(screen = Screen.HISTORY) {
HistoryScreen(
viewModel = historyViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
composable(screen = Screen.BROWSE) {
BrowseScreen(
viewModel = browseViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
composable(screen = Screen.LIBRARY) {
@Suppress("UNCHECKED_CAST")
LibraryScreen(
viewModel = libraryViewModel,
historyViewModel = historyViewModel,
browseViewModel = browseViewModel,
navigator = this@NavigationHost,
addedBooks = retrieveArgument("added_books") as? List<Book>
?: emptyList()
)
}
if (tabletUI) {
CustomNavigationRail(navigator = this@NavigationHost)
composable(screen = Screen.HISTORY) {
HistoryScreen(
viewModel = historyViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
composable(screen = Screen.BROWSE) {
BrowseScreen(
viewModel = browseViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
}
}
// Book Info
composable(
screen = Screen.BOOK_INFO,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
BookInfoScreen(
libraryViewModel = libraryViewModel,
browseViewModel = browseViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.READER,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderScreen(
mainViewModel = mainViewModel,
libraryViewModel = libraryViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
if (tabletUI) {
CustomNavigationRail(navigator = this@NavigationHost)
}
}
}
// Settings
composable(
screen = Screen.SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
SettingsScreen(
navigator = this@NavigationHost
)
}
// Book Info
composable(
screen = Screen.BOOK_INFO,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
BookInfoScreen(
libraryViewModel = libraryViewModel,
browseViewModel = browseViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.READER,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderScreen(
mainViewModel = mainViewModel,
libraryViewModel = libraryViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
}
// Nested categories
composable(
screen = Screen.GENERAL_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
GeneralSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.APPEARANCE_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
AppearanceSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.READER_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
// Settings
composable(
screen = Screen.SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
SettingsScreen(
navigator = this@NavigationHost
)
}
// Nested categories
composable(
screen = Screen.GENERAL_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
GeneralSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.APPEARANCE_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
AppearanceSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
composable(
screen = Screen.READER_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
// Start screen (later)
// composable(screen = Screen.START) {
// StartScreen()
// }
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surface
)
)
}
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
mainViewModel.onEvent(MainEvent.OnLocaleUpdate(this))
}
}

View file

@ -9,7 +9,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.UIText
import java.io.ByteArrayOutputStream
import java.io.File
@ -22,12 +21,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
val stream = ByteArrayOutputStream()
book.coverImage?.asAndroidBitmap()?.compress(
if (legacyAPI) Bitmap.CompressFormat.WEBP
else Bitmap.CompressFormat.WEBP_LOSSLESS,
if (legacyAPI) 30 else 100,
else Bitmap.CompressFormat.WEBP_LOSSY,
0,
stream
)
val text = book.text.joinToString("\n") {
val textAsString = book.text.joinToString("\n") {
it.line.trim()
}
@ -37,7 +36,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
filePath = book.filePath,
progress = book.progress,
author = book.author.string,
text = text,
text = textAsString,
description = book.description,
image = stream.toByteArray(),
category = book.category
@ -64,7 +63,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
description = bookEntity.description,
progress = bookEntity.progress,
file = if (file.exists()) file else null,
text = bookEntity.text.split("\n").map { StringWithId(it) },
text = emptyList(),
filePath = bookEntity.filePath,
lastOpened = null,
category = bookEntity.category,

View file

@ -37,11 +37,13 @@ class EpubFileParser @Inject constructor() : FileParser {
}
if (description != null) {
if (description.isEmpty()) {
if (description.isBlank()) {
description = null
}
}
return Book(
id = null,
title = title,

View file

@ -32,15 +32,27 @@ class EpubTextParser @Inject constructor() : TextParser {
for (spineReference in book.spine.spineReferences) {
val resource = spineReference.resource
val inputStream = resource.inputStream
val reader =
BufferedReader(InputStreamReader(inputStream, Charset.forName("UTF-8")))
val reader = BufferedReader(
InputStreamReader(
inputStream,
Charset.forName("UTF-8")
)
)
var line: String?
withContext(Dispatchers.IO) {
inputStream.close()
}
while (withContext(Dispatchers.IO) {
reader.readLine()
}.also { line = it } != null) {
unformattedText.append(line).append("\n")
}
withContext(Dispatchers.IO) {
reader.close()
}
}
val stringWithIds = mutableListOf<StringWithId>()

View file

@ -90,6 +90,11 @@ class BookRepositoryImpl @Inject constructor(
return database.findBooksById(ids).map { bookMapper.toBook(it) }
}
override suspend fun getBookTextById(bookId: Int): String {
val book = database.findBookById(bookId)
return book.text
}
override suspend fun findBook(
id: Int
): BookEntity {
@ -101,11 +106,39 @@ class BookRepositoryImpl @Inject constructor(
}
override suspend fun updateBooks(books: List<Book>) {
// without text
database.updateBooks(
books.map {
val book = database.findBookById(it.id ?: return)
bookMapper.toBookEntity(
it.copy(
text = book.text
.split("\n")
.map { line -> StringWithId(line.trim()) }
)
)
}
)
}
override suspend fun updateBooksWithText(books: List<Book>) {
database.updateBooks(books.map { bookMapper.toBookEntity(it) })
}
override suspend fun deleteBooks(books: List<Book>) {
database.deleteBooks(books.map { bookMapper.toBookEntity(it) })
// without text
database.deleteBooks(
books.map {
val book = database.findBookById(it.id ?: return)
bookMapper.toBookEntity(
it.copy(
text = book.text
.split("\n")
.map { line -> StringWithId(line.trim()) }
)
)
}
)
}
override suspend fun <T> retrieveDataFromDataStore(
@ -119,7 +152,7 @@ class BookRepositoryImpl @Inject constructor(
dataStore.putData(key, value)
}
override suspend fun getFilesFromDownloads(query: String): Flow<Resource<List<File>>> {
override suspend fun getFilesFromDevice(query: String): Flow<Resource<List<File>>> {
fun getAllFilesInDirectory(directory: File): List<File> {
val filesList = mutableListOf<File>()
@ -140,20 +173,15 @@ class BookRepositoryImpl @Inject constructor(
}
return flow {
emit(Resource.Loading(true))
val existingBooks = database
.searchBooks("")
.map { bookMapper.toBook(it) }
.filter { it.file != null }
val allFiles = getAllFilesInDirectory(
Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
)
val primaryDirectory = Environment.getExternalStorageDirectory()
val allFiles = getAllFilesInDirectory(primaryDirectory)
if (allFiles.isEmpty()) {
emit(Resource.Loading(false))
emit(Resource.Success(null))
return@flow
}
@ -168,7 +196,8 @@ class BookRepositoryImpl @Inject constructor(
)
}
val isFileAlreadyAdded = existingBooks.all {
it.file != file
it.filePath.substringAfterLast("/") !=
file.path.substringAfterLast("/")
}
val isQuery = if (query.isEmpty()) true else file.name.lowercase()
.contains(query.trim().lowercase())
@ -193,7 +222,6 @@ class BookRepositoryImpl @Inject constructor(
}.toMutableList()
}
emit(Resource.Loading(false))
emit(
Resource.Success(
data = filteredFiles

View file

@ -24,6 +24,10 @@ interface BookRepository {
ids: List<Int>
): List<Book>
suspend fun getBookTextById(
bookId: Int
): String
suspend fun findBook(
id: Int
): BookEntity
@ -36,6 +40,10 @@ interface BookRepository {
books: List<Book>
)
suspend fun updateBooksWithText(
books: List<Book>
)
suspend fun deleteBooks(
books: List<Book>
)
@ -50,7 +58,7 @@ interface BookRepository {
value: T
)
suspend fun getFilesFromDownloads(query: String = ""): Flow<Resource<List<File>>>
suspend fun getFilesFromDevice(query: String = ""): Flow<Resource<List<File>>>
suspend fun getBookTextFromFile(file: File): Flow<Resource<List<StringWithId>>>

View file

@ -1,27 +1,17 @@
package ua.acclorite.book_story.domain.use_case
import android.annotation.SuppressLint
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.util.DataStoreConstants
import java.util.Locale
import javax.inject.Inject
class ChangeLanguage @Inject constructor(private val repository: BookRepository) {
@SuppressLint("AppBundleLocaleChanges")
suspend fun execute(language: String, activity: ComponentActivity) {
val config = activity.resources.configuration
val resources = activity.resources
val locale = Locale(language)
Locale.setDefault(locale)
config.setLocale(locale)
activity.createConfigurationContext(config)
resources.updateConfiguration(
config,
resources.displayMetrics
)
suspend fun execute(language: String) {
val appLocale = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
repository.putDataToDataStore(
DataStoreConstants.LANGUAGE,

View file

@ -6,11 +6,11 @@ import ua.acclorite.book_story.util.Resource
import java.io.File
import javax.inject.Inject
class GetFilesFromDownloads @Inject constructor(
class GetFilesFromDevice @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(query: String): Flow<Resource<List<File>>> {
return repository.getFilesFromDownloads(query)
return repository.getFilesFromDevice(query)
}
}

View file

@ -1,15 +1,11 @@
package ua.acclorite.book_story.domain.use_case
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.util.Resource
import java.io.File
import javax.inject.Inject
class GetText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(file: File): Flow<Resource<List<StringWithId>>> {
return repository.getBookTextFromFile(file)
suspend fun execute(id: Int): String {
return repository.getBookTextById(bookId = id)
}
}

View file

@ -0,0 +1,12 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class UpdateBooksWithText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(books: List<Book>) {
repository.updateBooksWithText(books)
}
}

View file

@ -1,8 +1,11 @@
package ua.acclorite.book_story.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
@ -22,8 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.elevation
/**
@ -38,7 +42,7 @@ import ua.acclorite.book_story.ui.elevation
@Composable
fun AnimatedTopAppBar(
containerColor: Color = MaterialTheme.colorScheme.surface,
scrolledContainerColor: Color? = MaterialTheme.elevation(),
scrolledContainerColor: Color = MaterialTheme.elevation(),
scrollBehavior: TopAppBarScrollBehavior?,
isTopBarScrolled: Boolean?,
@ -58,79 +62,90 @@ fun AnimatedTopAppBar(
content3Title: @Composable () -> Unit = {},
content3Actions: @Composable RowScope.() -> Unit = {}
) {
//todo fix lags.
Box(modifier = Modifier.fillMaxWidth()) {
if ((scrollBehavior != null || isTopBarScrolled != null) && scrolledContainerColor != null) {
val fraction by remember {
derivedStateOf {
scrollBehavior?.state?.overlappedFraction ?: 0f
}
}
val isScrolled = if (scrollBehavior != null) {
fraction > 0.01f
} else isTopBarScrolled ?: false
val color = if (isScrolled) scrolledContainerColor else containerColor
val animatedColor by animateColorAsState(
targetValue = color,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "TopAppBar color animation"
)
val statusBarPadding = with(LocalDensity.current) {
WindowInsets.statusBars.getTop(LocalDensity.current).toDp()
}
val density = LocalDensity.current
val statusBarPadding = with(density) {
WindowInsets.statusBars.getTop(density).toDp()
}
if (containerColor != Color.Transparent) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp + statusBarPadding)
.background(
animatedColor
if (isTopBarScrolled == true) scrolledContainerColor
else containerColor
)
)
}
DefaultTransition(visible = content1Visibility ?: true) {
val scrollableContainerColor by remember(isTopBarScrolled) {
derivedStateOf {
if (isTopBarScrolled == true) {
scrolledContainerColor
} else {
containerColor
}
}
}
val animatedContainerColor by animateColorAsState(
targetValue = scrollableContainerColor,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = stringResource(id = R.string.top_app_bar_anim_content_desc)
)
AnimatedVisibility(
visible = content1Visibility ?: true,
enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
) {
TopAppBar(
navigationIcon = content1NavigationIcon,
title = content1Title,
actions = content1Actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
containerColor = animatedContainerColor,
scrolledContainerColor = scrolledContainerColor
)
)
}
if (content2Visibility != null) {
DefaultTransition(visible = content2Visibility) {
AnimatedVisibility(
visible = content2Visibility,
enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
) {
TopAppBar(
navigationIcon = content2NavigationIcon,
title = content2Title,
actions = content2Actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
containerColor = animatedContainerColor,
scrolledContainerColor = scrolledContainerColor
)
)
}
}
if (content3Visibility != null) {
DefaultTransition(visible = content3Visibility) {
AnimatedVisibility(
visible = content3Visibility,
enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
) {
TopAppBar(
navigationIcon = content3NavigationIcon,
title = content3Title,
actions = content3Actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
containerColor = animatedContainerColor,
scrolledContainerColor = scrolledContainerColor
)
)
}

View file

@ -13,14 +13,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.elevation
/**
* Custom Checkbox. Has a Circle shape.
*/
@Composable
fun CustomCheckbox(selected: Boolean) {
fun CustomCheckbox(selected: Boolean, size: Dp = 22.dp) {
Icon(
imageVector = Icons.Default.Check,
tint = if (selected) MaterialTheme.elevation(elevation = 2.dp) else Color.Transparent,
@ -36,7 +39,7 @@ fun CustomCheckbox(selected: Boolean) {
shape = CircleShape
)
.padding(4.dp)
.size(22.dp),
contentDescription = "checkbox"
.size(size),
contentDescription = stringResource(id = R.string.checkbox_content_desc)
)
}

View file

@ -0,0 +1,45 @@
package ua.acclorite.book_story.presentation.components
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Custom Icon Button. Can be disabled after click, so user can't press button fast 2 or 3 times.
*/
@Composable
fun CustomIconButton(
modifier: Modifier = Modifier,
icon: ImageVector,
contentDescription: String,
disableOnClick: Boolean,
enabled: Boolean = true,
color: Color = LocalContentColor.current,
onClick: () -> Unit
) {
var isClicked by remember { mutableStateOf(false) }
IconButton(
enabled = enabled && !isClicked,
onClick = {
if (disableOnClick) {
isClicked = true
}
onClick()
}
) {
Icon(
imageVector = icon,
modifier = modifier,
contentDescription = contentDescription,
tint = color
)
}
}

View file

@ -127,7 +127,6 @@ private class CustomSelectionToolbar(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
override var status: TextToolbarStatus by mutableStateOf(TextToolbarStatus.Hidden)
private set
override fun showMenu(
rect: Rect,

View file

@ -2,14 +2,7 @@ package ua.acclorite.book_story.presentation.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.data.Navigator
@ -19,20 +12,12 @@ import ua.acclorite.book_story.presentation.data.Navigator
*/
@Composable
fun GoBackButton(navigator: Navigator, customOnClick: () -> Unit = {}) {
var isClicked by remember { mutableStateOf(false) }
IconButton(
enabled = !isClicked,
onClick = {
isClicked = true
navigator.navigateBack()
customOnClick()
}
CustomIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.go_back_content_desc),
disableOnClick = true
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.go_back_content_desc),
tint = MaterialTheme.colorScheme.onSurface
)
navigator.navigateBack()
customOnClick()
}
}

View file

@ -2,13 +2,10 @@ package ua.acclorite.book_story.presentation.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -16,7 +13,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@ -31,19 +27,17 @@ import ua.acclorite.book_story.presentation.data.Screen
fun MoreDropDown(navigator: Navigator) {
var showDropDown by remember { mutableStateOf(false) }
val startPadding = 12.dp
val endPadding = 36.dp
val startPadding = remember { 12.dp }
val endPadding = remember { 36.dp }
Box {
IconButton(onClick = {
CustomIconButton(
icon = Icons.Default.MoreVert,
contentDescription = stringResource(id = R.string.show_dropdown_content_desc),
disableOnClick = false,
enabled = !showDropDown
) {
showDropDown = true
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Show drop down",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(
@ -51,7 +45,6 @@ fun MoreDropDown(navigator: Navigator) {
onDismissRequest = { showDropDown = false },
offset = DpOffset(10.dp, 0.dp)
) {
DropdownMenuItem(
text = {
Text(

View file

@ -18,6 +18,8 @@ import ua.acclorite.book_story.ui.elevation
/**
* Bottom navigation bar, uses default [NavigationBar].
*
* @param navigator Navigator.
*/
@Composable
fun BottomNavigationBar(

View file

@ -8,6 +8,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -23,6 +24,11 @@ fun RowScope.BottomNavigationBarItem(
isSelected: Boolean,
onClick: () -> Unit
) {
val icon = remember(isSelected) {
if (isSelected) item.selectedIcon
else item.unselectedIcon
}
NavigationBarItem(
label = {
Text(
@ -37,9 +43,7 @@ fun RowScope.BottomNavigationBarItem(
onClick = { onClick() },
icon = {
Icon(
painter =
if (isSelected) item.selectedIcon
else item.unselectedIcon,
painter = icon,
contentDescription = item.title,
modifier = Modifier.size(24.dp)
)

View file

@ -20,6 +20,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -69,6 +73,7 @@ fun CustomDialogWithContent(
withDivider: Boolean,
customContent: @Composable (ColumnScope.() -> Unit) = {}
) {
var actionClicked by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { onDismiss() },
properties = properties
@ -151,7 +156,13 @@ fun CustomDialogWithContent(
.align(Alignment.End)
.padding(horizontal = 24.dp)
) {
TextButton(onClick = { onDismiss() }) {
TextButton(
onClick = {
actionClicked = true
onDismiss()
},
enabled = !actionClicked
) {
Text(
text = stringResource(id = R.string.cancel),
style = MaterialTheme.typography.labelLarge,
@ -161,8 +172,11 @@ fun CustomDialogWithContent(
if (actionText != null) {
Spacer(modifier = Modifier.width(4.dp))
TextButton(
onClick = { onAction() },
enabled = isActionEnabled == true
onClick = {
actionClicked = true
onAction()
},
enabled = isActionEnabled == true && !actionClicked
) {
Text(
text = actionText,

View file

@ -21,6 +21,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -71,6 +75,7 @@ fun CustomDialogWithLazyColumn(
withDivider: Boolean,
items: (LazyListScope.() -> Unit) = {}
) {
var actionClicked by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { onDismiss() },
properties = properties
@ -156,7 +161,13 @@ fun CustomDialogWithLazyColumn(
.align(Alignment.End)
.padding(horizontal = 24.dp)
) {
TextButton(onClick = { onDismiss() }) {
TextButton(
onClick = {
actionClicked = true
onDismiss()
},
enabled = !actionClicked
) {
Text(
text = stringResource(id = R.string.cancel),
style = MaterialTheme.typography.labelLarge,
@ -166,8 +177,11 @@ fun CustomDialogWithLazyColumn(
if (actionText != null) {
Spacer(modifier = Modifier.width(4.dp))
TextButton(
onClick = { onAction() },
enabled = isActionEnabled == true
onClick = {
actionClicked = true
onAction()
},
enabled = isActionEnabled == true && !actionClicked
) {
Text(
text = actionText,

View file

@ -7,6 +7,7 @@ import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -22,6 +23,11 @@ fun CustomNavigationRailItem(
isSelected: Boolean,
onClick: () -> Unit
) {
val icon = remember(isSelected) {
if (isSelected) item.selectedIcon
else item.unselectedIcon
}
NavigationRailItem(
label = {
Text(
@ -36,9 +42,7 @@ fun CustomNavigationRailItem(
onClick = { onClick() },
icon = {
Icon(
painter =
if (isSelected) item.selectedIcon
else item.unselectedIcon,
painter = icon,
contentDescription = item.title,
modifier = Modifier.size(24.dp)
)

View file

@ -1,10 +1,7 @@
package ua.acclorite.book_story.presentation.data
import androidx.activity.ComponentActivity
sealed class MainEvent {
data class OnLocaleUpdate(val activity: ComponentActivity) : MainEvent()
data class OnChangeLanguage(val lang: String, val activity: ComponentActivity) : MainEvent()
data class OnChangeLanguage(val lang: String) : MainEvent()
data class OnChangeTheme(val theme: String) : MainEvent()
data class OnChangeDarkTheme(val darkTheme: String) : MainEvent()
data class OnChangeFontFamily(val fontFamily: String) : MainEvent()

View file

@ -1,14 +1,12 @@
package ua.acclorite.book_story.presentation.data
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -44,12 +42,16 @@ class MainViewModel @Inject constructor(
private val _isReady = MutableStateFlow(false)
val isReady = _isReady.asStateFlow()
private val _updating = MutableStateFlow(false)
val updating = _updating.asStateFlow()
/* -- Language ----------------------------------------------------- */
private var _language: String =
stateHandle[Constants.LANGUAGE] ?: Locale.getDefault().language
stateHandle[Constants.LANGUAGE]
?: if (
Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first }
) {
Locale.getDefault().language.take(2)
} else {
"en"
}
set(value) {
field = value
stateHandle[Constants.LANGUAGE] = value
@ -172,23 +174,9 @@ class MainViewModel @Inject constructor(
fun onEvent(event: MainEvent) {
when (event) {
is MainEvent.OnChangeLanguage -> {
viewModelScope.launch(Dispatchers.IO) {
_updating.update { true }
changeLanguage.execute(event.lang, event.activity)
viewModelScope.launch(Dispatchers.Main) {
changeLanguage.execute(event.lang)
_language = event.lang
delay(100)
_updating.update { false }
}
}
is MainEvent.OnLocaleUpdate -> {
viewModelScope.launch(Dispatchers.IO) {
_updating.update { true }
changeLanguage.execute(_language, event.activity)
delay(10)
_updating.update { false }
}
}
@ -266,7 +254,6 @@ class MainViewModel @Inject constructor(
}
fun init(
activity: ComponentActivity,
libraryViewModel: LibraryViewModel,
) {
val isViewModelsReady = combine(
@ -311,11 +298,11 @@ class MainViewModel @Inject constructor(
}
// Language
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Main) {
getDatastore
.execute(DataStoreConstants.LANGUAGE, _language)
.first {
onEvent(MainEvent.OnChangeLanguage(it, activity))
onEvent(MainEvent.OnChangeLanguage(it))
it.isNotBlank()
}
}

View file

@ -26,7 +26,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.pullrefresh.PullRefreshDefaults
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -34,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
@ -58,6 +56,7 @@ import dagger.hilt.android.lifecycle.withCreationCallback
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.CustomSnackbar
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.data.Argument
@ -114,8 +113,6 @@ fun BookInfoScreen(
val snackbarState = remember { SnackbarHostState() }
val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
refreshThreshold = PullRefreshDefaults.RefreshThreshold + 32.dp,
refreshingOffset = PullRefreshDefaults.RefreshingOffset + 64.dp,
onRefresh = {
viewModel.onEvent(
BookInfoEvent.OnUpdateBook(
@ -193,26 +190,21 @@ fun BookInfoScreen(
}
},
content1Actions = {
IconButton(
enabled = !state.isRefreshing,
onClick = {
viewModel.onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
)
}
CustomIconButton(
icon = Icons.Default.Refresh,
contentDescription = stringResource(id = R.string.refresh_book_content_desc),
disableOnClick = false,
enabled = !state.isRefreshing
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(id = R.string.refresh_book_content_desc),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
viewModel.onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
)
}
@ -230,34 +222,30 @@ fun BookInfoScreen(
enter = Transitions.DefaultTransitionIn,
exit = fadeOut(tween(200))
) {
IconButton(
CustomIconButton(
icon = Icons.Default.Done,
contentDescription = stringResource(id = R.string.apply_changes_content_desc),
disableOnClick = true,
enabled = state.titleValue.isNotBlank() &&
state.titleValue.trim() != state.book.title.trim(),
onClick = {
viewModel.onEvent(BookInfoEvent.OnUpdateTitle(
refreshList = {
libraryViewModel.onEvent(
LibraryEvent.OnLoadList
)
}
))
Toast.makeText(
context,
context.getString(R.string.title_changed),
Toast.LENGTH_LONG
).show()
}
state.titleValue.trim() !=
state.book.title.trim(),
color = if (state.titleValue.isNotBlank()
&& state.titleValue != state.book.title
) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = "Apply changes",
tint =
if (state.titleValue.isNotBlank()
&& state.titleValue != state.book.title
)
MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
)
viewModel.onEvent(BookInfoEvent.OnUpdateTitle(
refreshList = {
libraryViewModel.onEvent(
LibraryEvent.OnLoadList
)
}
))
Toast.makeText(
context,
context.getString(R.string.title_changed),
Toast.LENGTH_LONG
).show()
}
}
}
@ -354,7 +342,9 @@ fun BookInfoScreen(
PullRefreshIndicator(
state.isRefreshing,
refreshState,
Modifier.align(Alignment.TopCenter),
Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding()),
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)

View file

@ -36,10 +36,12 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
@ -76,7 +78,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Cover Image not found",
contentDescription = stringResource(id = R.string.cover_image_not_found_content_desc),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
@ -87,7 +89,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
if (book.coverImage != null) {
Image(
bitmap = book.coverImage,
contentDescription = "Cover",
contentDescription = stringResource(id = R.string.cover_image_content_desc),
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large),

View file

@ -1,26 +1,22 @@
package ua.acclorite.book_story.presentation.screens.book_info.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomDropDownMenuItem
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
@ -32,15 +28,13 @@ fun BookInfoMoreDropDown(viewModel: BookInfoViewModel, snackbarState: SnackbarHo
val state by viewModel.state.collectAsState()
Box {
IconButton(onClick = {
CustomIconButton(
icon = Icons.Default.MoreVert,
contentDescription = stringResource(id = R.string.show_dropdown_content_desc),
disableOnClick = false,
enabled = !state.showMoreDropDown
) {
viewModel.onEvent(BookInfoEvent.OnShowHideMoreDropDown)
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Show drop down",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(

View file

@ -9,6 +9,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.stringResource
@ -24,18 +25,29 @@ import ua.acclorite.book_story.presentation.data.removeTrailingZero
*/
@Composable
fun BookInfoStatisticSection(book: Book) {
val progress = remember(book) {
"${
(book.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%"
}
val description = if (book.progress < 1f) stringResource(
id = R.string.you_read_query,
progress
) + " " + stringResource(
if (book.progress > 0.2f) R.string.read_keep
else R.string.read_more
) else stringResource(id = R.string.read_done)
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Text(
text = "${
(book.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%",
text = progress,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge
)
@ -53,16 +65,7 @@ fun BookInfoStatisticSection(book: Book) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (book.progress < 1f) stringResource(
id = R.string.you_read_query,
(book.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero() + "%"
) + " " + stringResource(
if (book.progress > 0.2f) R.string.read_keep
else R.string.read_more
) else stringResource(id = R.string.read_done),
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,

View file

@ -6,8 +6,10 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HideImage
import androidx.compose.material.icons.filled.ImageSearch
@ -44,7 +46,8 @@ fun BookInfoChangeCoverBottomSheet(
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val book = state.book
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
@ -89,7 +92,7 @@ fun BookInfoChangeCoverBottomSheet(
)
}
if (book.coverImage != null) {
if (state.book.coverImage != null) {
BookInfoChangeCoverBottomSheetItem(
icon = Icons.Default.HideImage,
text = stringResource(id = R.string.delete_cover),
@ -109,6 +112,10 @@ fun BookInfoChangeCoverBottomSheet(
}
}
Spacer(modifier = Modifier.height(48.dp))
Spacer(
modifier = Modifier.height(
8.dp + navigationBarPadding
)
)
}
}

View file

@ -3,8 +3,10 @@ package ua.acclorite.book_story.presentation.screens.book_info.components.detail
import android.widget.Toast
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
@ -12,6 +14,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -35,20 +38,32 @@ fun BookInfoDetailsBottomSheet(
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val pattern = SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
val lastOpened = pattern.format(Date(state.book.lastOpened ?: 0))
val pattern = remember {
SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
}
val lastOpened = remember {
pattern.format(Date(state.book.lastOpened ?: 0))
}
val sizeBytes = state.book.file?.length() ?: 0
val sizeBytes = remember {
state.book.file?.length() ?: 0
}
val fileSizeKB = if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
val fileSizeMB = if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0
val fileSizeKB = remember {
if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
}
val fileSizeMB = remember {
if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0
}
val fileSize =
val fileSize = remember {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else ""
}
ModalBottomSheet(
modifier = Modifier.fillMaxWidth(),
@ -129,7 +144,10 @@ fun BookInfoDetailsBottomSheet(
}
}
Spacer(modifier = Modifier.height(48.dp))
Spacer(
modifier = Modifier.height(
8.dp + navigationBarPadding
)
)
}
}

View file

@ -34,9 +34,7 @@ fun BookInfoDetailsBottomSheetItem(
modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
},
onClick = {},
onLongClick = {
onClick()
}

View file

@ -27,6 +27,7 @@ import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.use_case.DeleteBooks
import ua.acclorite.book_story.domain.use_case.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.domain.use_case.UpdateBooksWithText
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
@ -35,6 +36,7 @@ import ua.acclorite.book_story.presentation.data.Screen
class BookInfoViewModel @AssistedInject constructor(
@Assisted book: Book,
private val updateBooks: UpdateBooks,
private val updateBooksWithText: UpdateBooksWithText,
private val deleteBooks: DeleteBooks,
private val getBookFromFile: GetBookFromFile
) : ViewModel() {
@ -42,7 +44,7 @@ class BookInfoViewModel @AssistedInject constructor(
private val _state = MutableStateFlow(BookInfoState(book))
val state = _state.asStateFlow()
var job: Job? = null
private var job: Job? = null
fun onEvent(event: BookInfoEvent) {
when (event) {
@ -286,7 +288,8 @@ class BookInfoViewModel @AssistedInject constructor(
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isRefreshing = true
isRefreshing = true,
editTitle = false
)
}
@ -358,13 +361,20 @@ class BookInfoViewModel @AssistedInject constructor(
_state.update {
it.copy(
book = it.book.copy(
author = updatedBook.author,
description = updatedBook.description
)
)
}
updateBooksWithText.execute(
listOf(
_state.value.book.copy(
author = updatedBook.author,
description = updatedBook.description,
text = updatedBook.text
)
)
}
updateBooks.execute(listOf(_state.value.book))
)
event.refreshList()
onEvent(

View file

@ -11,14 +11,10 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -29,8 +25,6 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -38,14 +32,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -55,11 +49,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.components.is_messages.IsError
@ -79,7 +71,7 @@ import ua.acclorite.book_story.ui.elevation
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class,
ExperimentalFoundationApi::class,
ExperimentalMaterialApi::class, FlowPreview::class
ExperimentalMaterialApi::class
)
@Composable
fun BrowseScreen(
@ -98,34 +90,21 @@ fun BrowseScreen(
}
)
val focusRequester = remember { FocusRequester() }
val listState = rememberLazyGridState(state.scrollIndex, state.scrollOffset)
var showErrorMessage by remember { mutableStateOf(false) }
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex
}
.debounce(10L)
.collectLatest {
viewModel.onEvent(BrowseEvent.OnUpdateScrollIndex(it))
}
}
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemScrollOffset
}
.debounce(10L)
.collectLatest {
viewModel.onEvent(
BrowseEvent.OnUpdateScrollOffset(it)
)
}
}
LaunchedEffect(Unit) {
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState))
viewModel.onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
}
if (state.requestPermissionDialog) {
BrowseStoragePermissionDialog(viewModel, permissionState)
BrowseStoragePermissionDialog(viewModel, permissionState) {
showErrorMessage = it
}
}
if (state.showAddingDialog) {
BrowseAddingDialog(
@ -146,7 +125,7 @@ fun BrowseScreen(
scrolledContainerColor = MaterialTheme.elevation(),
scrollBehavior = null,
isTopBarScrolled = state.scrollIndex > 0 || state.scrollOffset > 0 || state.hasSelectedItems,
isTopBarScrolled = state.hasSelectedItems || state.listState.canScrollBackward,
content1Visibility = !state.hasSelectedItems && !state.showSearch,
content1NavigationIcon = {},
@ -160,26 +139,26 @@ fun BrowseScreen(
)
},
content1Actions = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search files",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
CustomIconButton(
icon = Icons.Default.Search,
contentDescription = stringResource(id = R.string.search_content_desc),
disableOnClick = false,
enabled = !state.showSearch
) {
viewModel.onEvent(BrowseEvent.OnSearchShowHide)
}
MoreDropDown(navigator = navigator)
},
content2Visibility = state.hasSelectedItems,
content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnClearSelectedFiles) }) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear selected items",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
CustomIconButton(
icon = Icons.Default.Clear,
contentDescription =
stringResource(id = R.string.clear_selected_items_content_desc),
disableOnClick = true
) {
viewModel.onEvent(BrowseEvent.OnClearSelectedFiles)
}
},
content2Title = {
@ -195,25 +174,27 @@ fun BrowseScreen(
)
},
content2Actions = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnAddingDialogRequest) }) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Add files to library",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
CustomIconButton(
icon = Icons.Default.Check,
contentDescription =
stringResource(id = R.string.add_files_content_desc),
disableOnClick = false,
enabled = !state.showAddingDialog
) {
viewModel.onEvent(BrowseEvent.OnAddingDialogRequest)
}
},
content3Visibility = state.showSearch && !state.hasSelectedItems,
content3NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Exit search mode",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(
id = R.string.exit_search_content_desc
),
disableOnClick = true
) {
viewModel.onEvent(BrowseEvent.OnSearchShowHide)
}
},
content3Title = {
@ -268,52 +249,54 @@ fun BrowseScreen(
.fillMaxSize()
.padding(top = padding.calculateTopPadding())
) {
LazyVerticalGrid(
state = listState,
modifier = Modifier
.fillMaxSize(),
columns = GridCells.Adaptive(170.dp),
contentPadding = PaddingValues(12.dp)
) {
items(
state.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem(
file = selectableFile,
modifier = Modifier
.animateItemPlacement(),
onClick = {
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
}
)
DefaultTransition(visible = !state.isLoading) {
LazyColumn(
state = state.listState,
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
state.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem(
file = selectableFile,
modifier = Modifier
.animateItemPlacement(),
hasSelectedFiles = state.selectableFiles.any { it.second },
onClick = {
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
}
)
}
}
}
if (state.isLoading && !state.isRefreshing && state.selectableFiles.isEmpty()) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeCap = StrokeCap.Round,
modifier = Modifier
.align(Alignment.Center)
.size(36.dp)
)
}
DefaultTransition(visible = state.showErrorMessage, Modifier.align(Alignment.Center)) {
AnimatedVisibility(
visible = showErrorMessage,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = fadeOut(tween(0))
) {
IsError(
modifier = Modifier.align(Alignment.Center),
errorMessage = stringResource(id = R.string.error_permission),
icon = painterResource(id = R.drawable.error),
actionTitle = stringResource(id = R.string.grant_permission)
) {
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState))
viewModel.onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
}
}
AnimatedVisibility(
visible = !state.isLoading && state.selectableFiles.isEmpty()
&& !state.showErrorMessage && !state.requestPermissionDialog
&& !showErrorMessage && !state.requestPermissionDialog
&& !state.isRefreshing,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,

View file

@ -15,22 +15,25 @@ import androidx.compose.foundation.layout.height
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.filled.CheckCircle
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.ui.DefaultTransition
import java.io.File
import java.text.SimpleDateFormat
@ -41,19 +44,24 @@ import java.util.Locale
* Browse list element item. Can be selected.
*/
@Composable
fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) {
val fileExtension: String = file.first.name.substringAfterLast(".", "")
fun BrowseFileItem(
file: Pair<File, Boolean>,
hasSelectedFiles: Boolean,
modifier: Modifier, onClick: () -> Unit
) {
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
val lastModified = remember { pattern.format(Date(file.first.lastModified())) }
val sizeBytes = remember { file.first.length() }
val pattern = SimpleDateFormat("HH:mm dd MMM", Locale.getDefault())
val lastModified = pattern.format(Date(file.first.lastModified()))
val icon = when (fileExtension) {
"txt" -> painterResource(id = R.drawable.txt)
"epub" -> painterResource(id = R.drawable.epub)
"pdf" -> painterResource(id = R.drawable.pdf)
else -> painterResource(id = R.drawable.file)
val fileSizeKB = remember { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 }
val fileSizeMB = remember { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 }
val fileSize = remember {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "0 KB"
}
val outlineColor = if (file.second) MaterialTheme.colorScheme.primary
val outlineColor = if (file.second) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant
val backgroundColor = if (file.second) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
@ -61,90 +69,82 @@ fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () ->
val animatedOutlineColor by animateColorAsState(
targetValue = outlineColor,
tween(300),
label = "Outline animation"
label = stringResource(id = R.string.outline_anim_content_desc)
)
val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor,
tween(300),
label = "Background animation"
label = stringResource(id = R.string.background_anim_content_desc)
)
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.medium)
.border(
width = 1.dp,
color = animatedOutlineColor,
shape = MaterialTheme.shapes.medium
)
.padding(1.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 3.dp)
.clip(RoundedCornerShape(10.dp))
.background(animatedBackgroundColor)
.clickable {
onClick()
}
.padding(horizontal = 8.dp, vertical = 7.dp)
) {
Icon(
painter = icon,
contentDescription = "File icon",
modifier = Modifier
.padding(vertical = 60.dp)
.size(70.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 10.dp, vertical = 12.dp)
modifier = Modifier.weight(0.88f)
) {
Box {
DefaultTransition(visible = !file.second) {
Icon(
painter = painterResource(R.drawable.file),
contentDescription = "File",
modifier = Modifier
.size(28.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.border(
1.dp,
animatedOutlineColor,
RoundedCornerShape(6.dp)
)
}
DefaultTransition(visible = file.second) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Checked",
modifier = Modifier
.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column(
verticalArrangement = Arrangement.Center
.padding(14.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = stringResource(id = R.string.file_icon_content_desc),
modifier = Modifier
.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.Center) {
Text(
file.first.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
lastModified,
"$fileSize, $lastModified",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
Box(
modifier = Modifier
.weight(0.12f)
.padding(end = 6.dp),
contentAlignment = Alignment.CenterEnd
) {
DefaultTransition(
visible = hasSelectedFiles
) {
CustomCheckbox(selected = file.second, size = 18.dp)
}
}
}
}

View file

@ -19,7 +19,11 @@ import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: PermissionState) {
fun BrowseStoragePermissionDialog(
viewModel: BrowseViewModel,
permissionState: PermissionState,
showErrorMessage: (Boolean) -> Unit
) {
val activity = LocalContext.current as ComponentActivity
CustomDialogWithContent(
@ -27,15 +31,28 @@ fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: P
description = stringResource(id = R.string.storage_permission_description),
actionText = stringResource(id = R.string.grant),
imageVectorIcon = Icons.Default.SdStorage,
onDismiss = { viewModel.onEvent(BrowseEvent.OnStoragePermissionDismiss(permissionState)) },
onDismiss = {
viewModel.onEvent(
BrowseEvent.OnStoragePermissionDismiss(
permissionState,
showErrorMessage = { showErrorMessage(true) }
)
)
},
isActionEnabled = true,
withDivider = false,
onAction = {
viewModel.onEvent(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BrowseEvent.OnStoragePermissionRequest(activity)
BrowseEvent.OnStoragePermissionRequest(
activity,
hideErrorMessage = { showErrorMessage(false) }
)
} else {
BrowseEvent.OnLegacyStoragePermissionRequest(permissionState)
BrowseEvent.OnLegacyStoragePermissionRequest(
permissionState,
hideErrorMessage = { showErrorMessage(false) }
)
}
)
}

View file

@ -81,7 +81,7 @@ fun BrowseAddingDialogItem(result: NullableBook, onClick: (Boolean) -> Unit) {
) {
Icon(
imageVector = icon,
contentDescription = "Error",
contentDescription = stringResource(id = R.string.error_content_desc),
modifier = Modifier.size(26.dp),
tint = MaterialTheme.colorScheme.error
)

View file

@ -11,13 +11,24 @@ import ua.acclorite.book_story.presentation.data.Navigator
import java.io.File
sealed class BrowseEvent {
data class OnStoragePermissionRequest(val activity: ComponentActivity) : BrowseEvent()
data class OnLegacyStoragePermissionRequest(val permissionState: PermissionState) :
BrowseEvent()
data class OnStoragePermissionRequest(
val activity: ComponentActivity, val hideErrorMessage: () -> Unit
) : BrowseEvent()
data class OnLegacyStoragePermissionRequest(
val permissionState: PermissionState, val hideErrorMessage: () -> Unit
) : BrowseEvent()
data class OnStoragePermissionDismiss(
val permissionState: PermissionState, val showErrorMessage: () -> Unit
) : BrowseEvent()
data class OnStoragePermissionDismiss(val permissionState: PermissionState) : BrowseEvent()
data object OnRefreshList : BrowseEvent()
data class OnPermissionCheck(val permissionState: PermissionState) : BrowseEvent()
data class OnPermissionCheck(
val permissionState: PermissionState,
val hideErrorMessage: () -> Unit
) : BrowseEvent()
data class OnSelectFile(val file: Pair<File, Boolean>) : BrowseEvent()
data class OnSelectBook(val book: NullableBook) : BrowseEvent()
data object OnSearchShowHide : BrowseEvent()
@ -29,8 +40,5 @@ sealed class BrowseEvent {
data object OnGetBooksFromFiles : BrowseEvent()
data class OnAddBooks(val navigator: Navigator, val resetScroll: () -> Unit) : BrowseEvent()
data object OnLoadList : BrowseEvent()
data class OnUpdateScrollIndex(val index: Int) : BrowseEvent()
data class OnUpdateScrollOffset(val offset: Int) : BrowseEvent()
}

View file

@ -1,5 +1,6 @@
package ua.acclorite.book_story.presentation.screens.browse.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.NullableBook
import java.io.File
@ -7,10 +8,11 @@ import java.io.File
@Immutable
data class BrowseState(
val selectableFiles: List<Pair<File, Boolean>> = emptyList(),
val listState: LazyListState = LazyListState(0, 0),
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val requestPermissionDialog: Boolean = false,
val showErrorMessage: Boolean = false,
val selectedItemsCount: Int = 0,
val hasSelectedItems: Boolean = false,

View file

@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -20,7 +21,7 @@ import kotlinx.coroutines.launch
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.use_case.FastGetBooks
import ua.acclorite.book_story.domain.use_case.GetBooksFromFiles
import ua.acclorite.book_story.domain.use_case.GetFilesFromDownloads
import ua.acclorite.book_story.domain.use_case.GetFilesFromDevice
import ua.acclorite.book_story.domain.use_case.InsertBooks
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Screen
@ -31,7 +32,7 @@ import javax.inject.Inject
@HiltViewModel
class BrowseViewModel @Inject constructor(
private val getBooksFromFiles: GetBooksFromFiles,
private val getFilesFromDownloads: GetFilesFromDownloads,
private val getFilesFromDevice: GetFilesFromDevice,
private val insertBooks: InsertBooks,
private val fastGetBooks: FastGetBooks
) : ViewModel() {
@ -65,10 +66,10 @@ class BrowseViewModel @Inject constructor(
}
_state.update {
it.copy(
requestPermissionDialog = false,
showErrorMessage = false
requestPermissionDialog = false
)
}
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList)
break
}
@ -93,10 +94,10 @@ class BrowseViewModel @Inject constructor(
}
_state.update {
it.copy(
requestPermissionDialog = false,
showErrorMessage = false
requestPermissionDialog = false
)
}
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList)
break
}
@ -112,8 +113,7 @@ class BrowseViewModel @Inject constructor(
_state.update {
it.copy(
requestPermissionDialog = false,
showErrorMessage = !isPermissionGranted
requestPermissionDialog = false
)
}
@ -121,8 +121,9 @@ class BrowseViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
getFilesFromDownloads()
}
} else {
event.showErrorMessage()
}
}
is BrowseEvent.OnRefreshList -> {
@ -131,7 +132,8 @@ class BrowseViewModel @Inject constructor(
it.copy(
isRefreshing = true,
hasSelectedItems = false,
showSearch = false
showSearch = false,
listState = LazyListState(0, 0)
)
}
@ -146,55 +148,72 @@ class BrowseViewModel @Inject constructor(
}
is BrowseEvent.OnPermissionCheck -> {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val isPermissionGranted =
if (!legacyPermission) Environment.isExternalStorageManager()
else event.permissionState.status.isGranted
viewModelScope.launch(Dispatchers.IO) {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val isPermissionGranted =
if (!legacyPermission) Environment.isExternalStorageManager()
else event.permissionState.status.isGranted
if (isPermissionGranted) {
return
}
_state.update {
it.copy(
requestPermissionDialog = true,
showErrorMessage = false
)
if (isPermissionGranted) {
return@launch
}
_state.update {
it.copy(
requestPermissionDialog = true
)
}
event.hideErrorMessage()
}
}
is BrowseEvent.OnSelectFile -> {
val indexOfFile = _state.value.selectableFiles.indexOf(event.file)
val editedList = _state.value.selectableFiles.toMutableList()
editedList[indexOfFile] = editedList[indexOfFile].copy(
second = !editedList[indexOfFile].second
)
viewModelScope.launch(Dispatchers.IO) {
val indexOfFile = _state.value.selectableFiles.indexOf(event.file)
_state.update {
it.copy(
selectableFiles = editedList.toList(),
selectedItemsCount = editedList.filter { file -> file.second }.size,
hasSelectedItems = editedList.any { file -> file.second }
if (indexOfFile == -1) {
return@launch
}
val editedList = _state.value.selectableFiles.toMutableList()
editedList[indexOfFile] = editedList[indexOfFile].copy(
second = !editedList[indexOfFile].second
)
_state.update {
it.copy(
selectableFiles = editedList.toList(),
selectedItemsCount = editedList.filter { file -> file.second }.size,
hasSelectedItems = editedList.any { file -> file.second }
)
}
}
}
is BrowseEvent.OnSelectBook -> {
val indexOfFile = _state.value.selectedBooks.indexOf(event.book)
val editedList = _state.value.selectedBooks.toMutableList()
editedList[indexOfFile] = NullableBook.NotNull(
editedList[indexOfFile].book!!.copy(
second = !editedList[indexOfFile].book!!.second
)
)
viewModelScope.launch(Dispatchers.IO) {
val indexOfFile = _state.value.selectedBooks.indexOf(event.book)
if (!editedList.any { it.book?.second == true }) {
return
}
if (indexOfFile == -1) {
return@launch
}
_state.update {
it.copy(
selectedBooks = editedList
val editedList = _state.value.selectedBooks.toMutableList()
editedList[indexOfFile] = NullableBook.NotNull(
editedList[indexOfFile].book!!.copy(
second = !editedList[indexOfFile].book!!.second
)
)
if (!editedList.any { it.book?.second == true }) {
return@launch
}
_state.update {
it.copy(
selectedBooks = editedList
)
}
}
}
@ -331,35 +350,20 @@ class BrowseViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
isLoading = true,
listState = LazyListState(0, 0)
)
}
getFilesFromDownloads()
}
}
is BrowseEvent.OnUpdateScrollIndex -> {
_state.update {
it.copy(
scrollIndex = event.index
)
}
}
is BrowseEvent.OnUpdateScrollOffset -> {
_state.update {
it.copy(
scrollOffset = event.offset
)
}
}
}
}
private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
) {
getFilesFromDownloads.execute(query).collect { result ->
getFilesFromDevice.execute(query).collect { result ->
when (result) {
is Resource.Success -> {
_state.update {
@ -371,13 +375,7 @@ class BrowseViewModel @Inject constructor(
}
}
is Resource.Loading -> {
_state.update {
it.copy(
isLoading = result.isLoading
)
}
}
is Resource.Loading -> Unit
is Resource.Error -> Unit
}

View file

@ -12,13 +12,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -28,24 +25,19 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -54,12 +46,10 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.CustomSnackbar
import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
@ -72,13 +62,14 @@ import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation
import java.util.UUID
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class, FlowPreview::class, ExperimentalFoundationApi::class
ExperimentalMaterialApi::class, ExperimentalFoundationApi::class
)
@Composable
fun HistoryScreen(
@ -89,7 +80,6 @@ fun HistoryScreen(
val context = LocalContext.current
val state by viewModel.state.collectAsState()
val books = libraryViewModel.state.collectAsState().value.books.map { it.first }
val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = {
@ -98,28 +88,6 @@ fun HistoryScreen(
)
val focusRequester = remember { FocusRequester() }
val snackbarState = remember { SnackbarHostState() }
val listState = rememberLazyListState(state.scrollIndex, state.scrollOffset)
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex
}
.debounce(10L)
.collectLatest {
viewModel.onEvent(HistoryEvent.OnUpdateScrollIndex(it))
}
}
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemScrollOffset
}
.debounce(10L)
.collectLatest {
viewModel.onEvent(
HistoryEvent.OnUpdateScrollOffset(it)
)
}
}
if (state.showDeleteWholeHistoryDialog) {
HistoryDeleteWholeHistoryDialog(viewModel = viewModel, libraryViewModel = libraryViewModel)
@ -134,7 +102,7 @@ fun HistoryScreen(
AnimatedTopAppBar(
scrolledContainerColor = MaterialTheme.elevation(),
scrollBehavior = null,
isTopBarScrolled = (state.scrollIndex > 0 || state.scrollOffset > 0) && !state.isLoading,
isTopBarScrolled = state.listState.canScrollBackward,
content1Visibility = !state.showSearch,
content1NavigationIcon = {},
@ -148,43 +116,39 @@ fun HistoryScreen(
)
},
content1Actions = {
IconButton(
enabled = !state.isRefreshing,
onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }
CustomIconButton(
icon = Icons.Default.Search,
contentDescription = stringResource(id = R.string.search_content_desc),
disableOnClick = false,
enabled = !state.showSearch
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search history",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
viewModel.onEvent(HistoryEvent.OnSearchShowHide)
}
IconButton(
CustomIconButton(
icon = Icons.Outlined.DeleteSweep,
contentDescription = stringResource(
id = R.string.delete_whole_history_content_desc
),
disableOnClick = false,
enabled = !state.isLoading
&& !state.isRefreshing
&& state.history.isNotEmpty(),
onClick = {
viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
}
&& state.history.isNotEmpty()
) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete whole history",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
}
MoreDropDown(navigator = navigator)
},
content2Visibility = state.showSearch,
content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Exit search mode",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(
id = R.string.exit_search_content_desc
),
disableOnClick = true
) {
viewModel.onEvent(HistoryEvent.OnSearchShowHide)
}
},
content2Title = {
@ -243,68 +207,72 @@ fun HistoryScreen(
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding())
) {
LazyColumn(
Modifier
.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(vertical = 12.dp)
) {
if (!state.isLoading) {
state.history.forEachIndexed { index, groupedHistory ->
item(key = groupedHistory.title) {
if (index > 0) {
DefaultTransition(visible = !state.isLoading) {
LazyColumn(
Modifier
.fillMaxSize(),
state = state.listState,
contentPadding = PaddingValues(vertical = 12.dp)
) {
if (!state.isLoading) {
state.history.forEachIndexed { index, groupedHistory ->
item(key = groupedHistory.title) {
if (index > 0) {
Spacer(modifier = Modifier.height(8.dp))
}
CategoryTitle(
modifier = Modifier.animateItemPlacement(),
title = when (groupedHistory.title) {
"today" -> stringResource(id = R.string.today)
"yesterday" -> stringResource(id = R.string.yesterday)
else -> groupedHistory.title
},
padding = 16.dp
)
Spacer(modifier = Modifier.height(8.dp))
}
CategoryTitle(
modifier = Modifier.animateItemPlacement(),
title = when (groupedHistory.title) {
"today" -> stringResource(id = R.string.today)
"yesterday" -> stringResource(id = R.string.yesterday)
else -> groupedHistory.title
},
padding = 16.dp
)
Spacer(modifier = Modifier.height(8.dp))
}
items(
groupedHistory.history, key = { it.id ?: UUID.randomUUID() }
) {
val book = remember {
books.find { book -> book.id == it.bookId }
} ?: return@items
items(
groupedHistory.history, key = { it.id ?: UUID.randomUUID() }
) {
val book = books.find { book -> book.id == it.bookId } ?: return@items
HistoryItem(
modifier = Modifier.animateItemPlacement(),
history = it,
book = book,
onBodyClick = {
navigator.navigate(
Screen.BOOK_INFO,
false,
Argument("book", book)
)
},
onTitleClick = {
navigator.navigate(
Screen.READER,
false,
Argument("book", book)
)
},
isDeleteEnabled = !state.isRefreshing,
onDeleteClick = {
viewModel.onEvent(
HistoryEvent.OnDeleteHistoryElement(
historyToDelete = it,
snackbarState = snackbarState,
context = context,
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
HistoryItem(
modifier = Modifier.animateItemPlacement(),
history = it,
book = book,
onBodyClick = {
navigator.navigate(
Screen.BOOK_INFO,
false,
Argument("book", book)
)
)
}
)
},
onTitleClick = {
navigator.navigate(
Screen.READER,
false,
Argument("book", book)
)
},
isDeleteEnabled = !state.isRefreshing,
onDeleteClick = {
viewModel.onEvent(
HistoryEvent.OnDeleteHistoryElement(
historyToDelete = it,
snackbarState = snackbarState,
context = context,
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
)
)
}
)
}
}
}
}
@ -322,15 +290,6 @@ fun HistoryScreen(
icon = painterResource(id = R.drawable.empty_history)
)
}
if (state.isLoading && !state.isRefreshing) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeCap = StrokeCap.Round,
modifier = Modifier
.align(Alignment.Center)
.size(36.dp)
)
}
PullRefreshIndicator(
state.isRefreshing,

View file

@ -23,6 +23,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -51,8 +52,10 @@ fun HistoryItem(
isDeleteEnabled: Boolean,
onDeleteClick: () -> Unit
) {
val date = Date(history.time)
val pattern = SimpleDateFormat("HH:mm", Locale.getDefault())
val date = remember(history) { Date(history.time) }
val pattern = remember {
SimpleDateFormat("HH:mm", Locale.getDefault())
}
Row(
verticalAlignment = Alignment.CenterVertically,
@ -65,7 +68,7 @@ fun HistoryItem(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(0.9f)
modifier = Modifier.weight(0.89f)
) {
Box(
modifier = Modifier
@ -76,7 +79,9 @@ fun HistoryItem(
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Cover Image not found",
contentDescription = stringResource(
id = R.string.cover_image_not_found_content_desc
),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
@ -128,7 +133,7 @@ fun HistoryItem(
)
}
}
Box(modifier = Modifier.weight(0.1f), contentAlignment = Alignment.CenterEnd) {
Box(modifier = Modifier.weight(0.11f), contentAlignment = Alignment.CenterEnd) {
IconButton(
enabled = isDeleteEnabled,
onClick = {

View file

@ -20,6 +20,4 @@ sealed class HistoryEvent {
data class OnSearchQueryChange(val query: String) : HistoryEvent()
data object OnSearchShowHide : HistoryEvent()
data class OnRequestFocus(val focusRequester: FocusRequester) : HistoryEvent()
data class OnUpdateScrollIndex(val index: Int) : HistoryEvent()
data class OnUpdateScrollOffset(val offset: Int) : HistoryEvent()
}

View file

@ -1,9 +1,12 @@
package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.foundation.lazy.LazyListState
import ua.acclorite.book_story.domain.model.GroupedHistory
data class HistoryState(
val history: List<GroupedHistory> = emptyList(),
val listState: LazyListState = LazyListState(0, 0),
val isRefreshing: Boolean = false,
val isLoading: Boolean = true,

View file

@ -1,5 +1,6 @@
package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.SnackbarResult
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -58,7 +59,8 @@ class HistoryViewModel @Inject constructor(
_state.update {
it.copy(
isRefreshing = true,
showSearch = false
showSearch = false,
listState = LazyListState(0, 0),
)
}
@ -76,12 +78,11 @@ class HistoryViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
isLoading = true,
listState = LazyListState(0, 0),
)
}
getHistoryFromDatabase()
onEvent(HistoryEvent.OnUpdateScrollIndex(0))
onEvent(HistoryEvent.OnUpdateScrollOffset(0))
}
}
@ -89,7 +90,8 @@ class HistoryViewModel @Inject constructor(
viewModelScope.launch {
_state.update {
it.copy(
showDeleteWholeHistoryDialog = false
showDeleteWholeHistoryDialog = false,
isLoading = true
)
}
@ -109,7 +111,7 @@ class HistoryViewModel @Inject constructor(
}
is HistoryEvent.OnDeleteHistoryElement -> {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
deleteHistory.execute(
listOf(event.historyToDelete)
)
@ -186,22 +188,6 @@ class HistoryViewModel @Inject constructor(
}
}
}
is HistoryEvent.OnUpdateScrollIndex -> {
_state.update {
it.copy(
scrollIndex = event.index
)
}
}
is HistoryEvent.OnUpdateScrollOffset -> {
_state.update {
it.copy(
scrollOffset = event.offset
)
}
}
}
}
@ -224,16 +210,12 @@ class HistoryViewModel @Inject constructor(
getHistory.execute().collect { result ->
when (result) {
is Resource.Success -> {
_state.update {
it.copy(
history = emptyList()
)
}
val history = result.data?.sortedByDescending { it.time } ?: emptyList()
if (history.isEmpty()) {
_state.update {
it.copy(
history = emptyList(),
isLoading = false
)
}

View file

@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -26,7 +25,6 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -38,8 +36,6 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -58,7 +54,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -74,6 +69,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.data.Argument
@ -87,6 +83,7 @@ import ua.acclorite.book_story.presentation.screens.library.components.dialog.Li
import ua.acclorite.book_story.presentation.screens.library.components.dialog.LibraryMoveDialog
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation
import java.util.UUID
@ -178,26 +175,26 @@ fun LibraryScreen(
}
},
content1Actions = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnSearchShowHide) }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search books",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
CustomIconButton(
icon = Icons.Default.Search,
contentDescription = stringResource(id = R.string.search_content_desc),
disableOnClick = false,
enabled = !state.showSearch
) {
viewModel.onEvent(LibraryEvent.OnSearchShowHide)
}
MoreDropDown(navigator = navigator)
},
content2Visibility = state.hasSelectedItems,
content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnClearSelectedBooks) }) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear selected items",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
CustomIconButton(
icon = Icons.Default.Clear,
contentDescription =
stringResource(id = R.string.clear_selected_items_content_desc),
disableOnClick = true
) {
viewModel.onEvent(LibraryEvent.OnClearSelectedBooks)
}
},
content2Title = {
@ -213,37 +210,38 @@ fun LibraryScreen(
)
},
content2Actions = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.DriveFileMove,
contentDescription = "Move books to another category",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
CustomIconButton(
icon = Icons.AutoMirrored.Outlined.DriveFileMove,
contentDescription = stringResource(
id = R.string.move_books_content_desc
),
disableOnClick = true
) {
viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog)
}
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnShowHideDeleteDialog) }) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = "Delete books from database",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
CustomIconButton(
icon = Icons.Outlined.Delete,
contentDescription = stringResource(
id = R.string.delete_books_content_desc
),
disableOnClick = true
) {
viewModel.onEvent(LibraryEvent.OnShowHideDeleteDialog)
}
},
content3Visibility = state.showSearch && !state.hasSelectedItems,
content3NavigationIcon = {
IconButton(onClick = {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(
id = R.string.exit_search_content_desc
),
disableOnClick = true
) {
viewModel.onEvent(
LibraryEvent.OnSearchShowHide
)
}) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Exit search mode",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
}
},
content3Title = {
@ -307,7 +305,7 @@ fun LibraryScreen(
HorizontalPager(state = pagerState, userScrollEnabled = !state.isRefreshing) { index ->
var categoryIsLoading by remember { mutableStateOf(true) }
val categorizedBooks = remember { mutableStateListOf<Pair<Book, Boolean>>() }
val category = Category.entries[index]
val category = remember { Category.entries[index] }
LaunchedEffect(state.books) {
categorizedBooks.clear()
@ -321,13 +319,13 @@ fun LibraryScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
if (!state.isLoading) {
DefaultTransition(visible = !state.isLoading && !categoryIsLoading) {
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(
categorizedBooks,
key = { it.first.id ?: UUID.randomUUID() }
@ -363,16 +361,6 @@ fun LibraryScreen(
}
}
if (state.isLoading && !state.isRefreshing) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeCap = StrokeCap.Round,
modifier = Modifier
.align(Alignment.Center)
.size(36.dp)
)
}
AnimatedVisibility(
visible = !state.isLoading && !state.isRefreshing && categorizedBooks.isEmpty()
&& !categoryIsLoading,
@ -404,7 +392,7 @@ fun LibraryScreen(
val activity = LocalContext.current as ComponentActivity
val scope = rememberCoroutineScope()
var shouldExit = false
var shouldExit = remember { false }
BackHandler {
if (state.hasSelectedItems) {
viewModel.onEvent(LibraryEvent.OnClearSelectedBooks)

View file

@ -25,14 +25,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.data.removeDigits
import ua.acclorite.book_story.presentation.data.removeTrailingZero
@ -55,6 +58,15 @@ fun LibraryBookItem(
val fontColor = if (book.second) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
val progress = remember(book.first) {
"${
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%"
}
val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor,
tween(300),
@ -93,7 +105,9 @@ fun LibraryBookItem(
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Cover Image not found",
contentDescription = stringResource(
id = R.string.cover_image_not_found_content_desc
),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
@ -104,7 +118,7 @@ fun LibraryBookItem(
if (book.first.coverImage != null) {
Image(
bitmap = book.first.coverImage!!,
contentDescription = "Cover",
contentDescription = stringResource(id = R.string.cover_image_content_desc),
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large),
@ -113,12 +127,7 @@ fun LibraryBookItem(
}
Text(
"${
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%",
progress,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier

View file

@ -18,11 +18,12 @@ import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
@ -37,24 +38,27 @@ import ua.acclorite.book_story.ui.elevation
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: PagerState) {
val tabItems = listOf(
Pair(
stringResource(id = R.string.reading_tab),
books.filter { it.category == Category.READING }.size
),
Pair(
stringResource(id = R.string.already_read_tab),
books.filter { it.category == Category.ALREADY_READ }.size
),
Pair(
stringResource(id = R.string.planning_tab),
books.filter { it.category == Category.PLANNING }.size
),
Pair(
stringResource(id = R.string.dropped_tab),
books.filter { it.category == Category.DROPPED }.size
val context = LocalContext.current
val tabItems = remember(books) {
listOf(
Pair(
context.getString(R.string.reading_tab),
books.filter { it.category == Category.READING }.size
),
Pair(
context.getString(R.string.already_read_tab),
books.filter { it.category == Category.ALREADY_READ }.size
),
Pair(
context.getString(R.string.planning_tab),
books.filter { it.category == Category.PLANNING }.size
),
Pair(
context.getString(R.string.dropped_tab),
books.filter { it.category == Category.DROPPED }.size
)
)
)
}
if (LocalConfiguration.current.screenWidthDp > 450) {
TabRow(

View file

@ -156,18 +156,25 @@ class LibraryViewModel @Inject constructor(
}
is LibraryEvent.OnSelectBook -> {
val indexOfBook = _state.value.books.indexOf(event.book)
val editedList = _state.value.books.toMutableList()
editedList[indexOfBook] = editedList[indexOfBook].copy(
second = event.select ?: !editedList[indexOfBook].second
)
viewModelScope.launch(Dispatchers.IO) {
val indexOfBook = _state.value.books.indexOf(event.book)
_state.update {
it.copy(
books = editedList.toList(),
selectedItemsCount = editedList.filter { book -> book.second }.size,
hasSelectedItems = editedList.any { book -> book.second }
if (indexOfBook == -1) {
return@launch
}
val editedList = _state.value.books.toMutableList()
editedList[indexOfBook] = editedList[indexOfBook].copy(
second = event.select ?: !editedList[indexOfBook].second
)
_state.update {
it.copy(
books = editedList.toList(),
selectedItemsCount = editedList.filter { book -> book.second }.size,
hasSelectedItems = editedList.any { book -> book.second }
)
}
}
}
@ -252,7 +259,8 @@ class LibraryViewModel @Inject constructor(
viewModelScope.launch {
_state.update {
it.copy(
showDeleteDialog = false
showDeleteDialog = false,
isLoading = true
)
}
@ -269,17 +277,20 @@ class LibraryViewModel @Inject constructor(
}
is LibraryEvent.OnUpdateBook -> {
val books = _state.value.books.toMutableList()
viewModelScope.launch(Dispatchers.IO) {
val books = _state.value.books.toMutableList()
val index = books.indexOfFirst { it.first.id == event.book.id }
if (index == -1) {
return@launch
}
val index = books.indexOfFirst { it.first.id == event.book.id }
if (index != -1) {
books[index] = Pair(event.book, books[index].second)
}
_state.update {
it.copy(
books = books
)
_state.update {
it.copy(
books = books
)
}
}
}
}
@ -300,14 +311,7 @@ class LibraryViewModel @Inject constructor(
}
}
is Resource.Loading -> {
_state.update {
it.copy(
isLoading = result.isLoading
)
}
}
is Resource.Loading -> Unit
is Resource.Error -> Unit
}
}

View file

@ -150,16 +150,19 @@ fun ReaderScreen(
}
)
}
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex
}.debounce(50).collectLatest {
}.debounce(1000).collectLatest {
if (!loading) {
val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.last().index
val progress = if (it > 0) {
if ((it + listState.layoutInfo.visibleItemsInfo.size) >= state.book.text.lastIndex) {
if (lastVisibleItemIndex >= (listState.layoutInfo.totalItemsCount - 1)) {
1f
} else {
(it.toFloat() / (state.book.text.size - 1).toFloat())
(it.toFloat() / (state.book.text.lastIndex).toFloat())
}
} else {
0f
@ -264,10 +267,10 @@ fun ReaderScreen(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.then(
if (!loading && toolbarShowed) {
Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
@ -279,7 +282,6 @@ fun ReaderScreen(
)
} else {
Modifier
.fillMaxSize()
}
)
) {
@ -293,7 +295,11 @@ fun ReaderScreen(
itemsIndexed(
state.book.text, key = { _, key -> key.id }
) { index, text ->
) { index, line ->
val text = remember { "${if (paragraphIndentation) " " else ""}${line.line}" }
val color = remember { Color(fontColor.toULong()) }
val lineHeightSp = remember { (fontSize + lineHeight).sp }
Column(
Modifier
.background(Color(backgroundColor.toULong()))
@ -307,8 +313,8 @@ fun ReaderScreen(
)
) {
Text(
text = "${if (paragraphIndentation) " " else ""}${text.line}",
color = Color(fontColor.toULong()),
text = text,
color = color,
style = TextStyle(
lineBreak = LineBreak.Paragraph
@ -316,7 +322,7 @@ fun ReaderScreen(
fontFamily = fontFamily.font,
fontStyle = fontStyle,
fontSize = fontSize.sp,
lineHeight = (fontSize + lineHeight).sp
lineHeight = lineHeightSp
)
}
}

View file

@ -64,7 +64,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Cover Image not found",
contentDescription = stringResource(id = R.string.cover_image_not_found_content_desc),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
@ -75,7 +75,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
if (state.book.coverImage != null) {
Image(
bitmap = state.book.coverImage!!,
contentDescription = "Cover",
contentDescription = stringResource(id = R.string.cover_image_content_desc),
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium),

View file

@ -14,7 +14,9 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.graphics.Color
@ -39,7 +41,15 @@ fun ReaderBottomBar(
systemBarsColor: Color
) {
val state by viewModel.state.collectAsState()
val book = state.book
val progress by remember(state.book.progress) {
derivedStateOf {
(state.book.progress * 100)
.toDouble()
.removeDigits(4)
.removeTrailingZero()
.dropWhile { it == '-' } + "%"
}
}
Column(
Modifier
@ -57,17 +67,12 @@ fun ReaderBottomBar(
verticalArrangement = Arrangement.Center
) {
Text(
text =
(book.progress * 100)
.toDouble()
.removeDigits(4)
.removeTrailingZero()
.dropWhile { it == '-' } + "%",
text = progress,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge
)
Slider(
value = book.progress,
value = state.book.progress,
onValueChange = {
viewModel.onEvent(
ReaderEvent.OnChangeProgress(

View file

@ -3,20 +3,18 @@ package ua.acclorite.book_story.presentation.screens.reader.components.app_bar
import androidx.activity.ComponentActivity
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@ -27,6 +25,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Navigator
@ -44,7 +43,16 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColor: Color) {
val context = LocalContext.current as ComponentActivity
val state by viewModel.state.collectAsState()
val book = state.book
val progress by remember(state.book.progress) {
derivedStateOf {
(state.book.progress * 100)
.toDouble()
.removeDigits(2)
.removeTrailingZero()
.dropWhile { it == '-' } + "%"
}
}
TopAppBar(
navigationIcon = {
@ -55,7 +63,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
title = {
Column(verticalArrangement = Arrangement.Center) {
Text(
book.title,
state.book.title,
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp,
@ -63,7 +71,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
maxLines = 1,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = null,
onClick = {
navigator.navigateWithoutBackStack(
@ -71,7 +79,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
true,
Argument(
"book",
book
state.book
)
)
}
@ -83,11 +91,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
Text(
stringResource(
id = R.string.read_query,
(book.progress * 100)
.toDouble()
.removeDigits(2)
.removeTrailingZero()
.dropWhile { it == '-' } + "%"
progress
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge
@ -95,13 +99,12 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
}
},
actions = {
IconButton(onClick = {
CustomIconButton(
icon = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.open_reader_settings_content_desc),
disableOnClick = false
) {
viewModel.onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
}) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.open_reader_settings_content_desc),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(

View file

@ -74,15 +74,18 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
val backgroundColor = mainViewModel.backgroundColor.collectAsState().value!!
val fontColor = mainViewModel.fontColor.collectAsState().value!!
val scrimColor =
if (currentPage == 1) Color.Transparent else BottomSheetDefaults.ScrimColor
val scrimColor = if (currentPage == 1) Color.Transparent
else BottomSheetDefaults.ScrimColor
val animatedScrimColor by animateColorAsState(
targetValue = scrimColor,
animationSpec = tween(300),
label = "Scrim animation"
)
val height = if (currentPage == 1) 0.5f else 0.7f
val height = remember(currentPage) {
if (currentPage == 1) 0.5f else 0.7f
}
val animatedHeight by animateFloatAsState(
targetValue = height,
animationSpec = tween(300),

View file

@ -15,7 +15,8 @@ sealed class ReaderEvent {
val scrollState: LazyListState,
val navigator: Navigator,
val refreshList: (Book) -> Unit,
val onLoaded: () -> Unit
val onLoaded: () -> Unit,
val onTextIsEmpty: () -> Unit
) : ReaderEvent()
data class OnShowHideMenu(val show: Boolean? = null, val context: ComponentActivity) :

View file

@ -13,9 +13,6 @@ data class ReaderState(
val errorMessage: UIText? = null,
val showMenu: Boolean = false,
val showSettingsBottomSheet: Boolean = false,
val currentPage: Int = 0,
)
)

View file

@ -26,6 +26,8 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.use_case.GetText
import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.presentation.data.Argument
@ -40,7 +42,8 @@ import kotlin.math.roundToInt
class ReaderViewModel @AssistedInject constructor(
@Assisted book: Book,
private val updateBooks: UpdateBooks,
private val insertHistory: InsertHistory
private val insertHistory: InsertHistory,
private val getText: GetText
) : ViewModel() {
private val _state = MutableStateFlow(ReaderState(book))
@ -59,14 +62,27 @@ class ReaderViewModel @AssistedInject constructor(
is ReaderEvent.OnLoadText -> {
viewModelScope.launch(Dispatchers.IO) {
val text = getText.execute(_state.value.book.id!!)
if (text.isBlank()) {
event.onTextIsEmpty()
}
val time = Date().time
val text = _state.value.book.text.joinToString(separator = " ") { it.line }
val letters = text.replace(" ", "").length
val words = text.split("\\s+".toRegex()).size
val letters = text
.replace("\n", "")
.length
val words = text
.replace("\n", " ")
.split("\\s+".toRegex())
.size
_state.update {
it.copy(
book = it.book.copy(
text = text
.split("\n")
.map { line -> StringWithId(line.trim()) },
lastOpened = time
),
letters = letters,
@ -98,14 +114,20 @@ class ReaderViewModel @AssistedInject constructor(
val scrollTo = (itemsCount * _state.value.book.progress).roundToInt()
if (itemsCount >= _state.value.book.text.size) {
if (scrollTo > 0) {
while (true) {
var loaded = false
for (i in 1..100) {
try {
event.scrollState.scrollToItem(scrollTo)
loaded = true
break
} catch (e: Exception) {
delay(50)
delay(100)
}
}
if (!loaded) {
event.onTextIsEmpty()
}
}
delay(100)
@ -172,12 +194,12 @@ class ReaderViewModel @AssistedInject constructor(
}
updateBooks.execute(
listOf(_state.value.book)
listOf(_state.value.book.copy(progress = event.progress))
)
event.navigator.putArgument(
Argument("book", _state.value.book)
Argument("book", _state.value.book.copy(progress = event.progress))
)
event.refreshList(_state.value.book)
event.refreshList(_state.value.book.copy(progress = event.progress))
}
}
@ -290,21 +312,20 @@ class ReaderViewModel @AssistedInject constructor(
ReaderState(book = book)
}
if (book.text.isEmpty()) {
onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() }))
} else {
onEvent(ReaderEvent.OnShowHideMenu(false, context))
onEvent(
ReaderEvent.OnLoadText(
scrollState,
navigator,
refreshList = { refreshList(it) },
onLoaded = {
onLoaded()
}
)
onEvent(ReaderEvent.OnShowHideMenu(false, context))
onEvent(
ReaderEvent.OnLoadText(
scrollState,
navigator,
refreshList = { refreshList(it) },
onLoaded = {
onLoaded()
},
onTextIsEmpty = {
onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() }))
}
)
}
)
}
}

View file

@ -1,7 +1,9 @@
package ua.acclorite.book_story.presentation.screens.settings
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
@ -21,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.data.Navigator
@ -97,6 +100,8 @@ fun SettingsScreen(
navigator.navigate(Screen.READER_SETTINGS, false)
}
}
item { Spacer(modifier = Modifier.height(48.dp)) }
}
}
}

View file

@ -119,7 +119,7 @@ private fun RevertibleSlider(
) {
Icon(
imageVector = Icons.Default.History,
contentDescription = "Revert to initial",
contentDescription = stringResource(id = R.string.revert_content_desc),
modifier = Modifier
.size(28.dp),
tint = if (initialValue == value.first) MaterialTheme.colorScheme.onSurfaceVariant

View file

@ -160,6 +160,8 @@ fun AppearanceSettings(
}
)
}
item { Spacer(modifier = Modifier.height(48.dp)) }
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -31,8 +32,10 @@ fun AppearanceSettingsThemeSwitcher(
val theme = mainViewModel.theme.collectAsState().value!!
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
val themes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Constants.THEMES
else Constants.THEMES.dropWhile { it.first == Theme.DYNAMIC }
val themes = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Constants.THEMES
else Constants.THEMES.dropWhile { it.first == Theme.DYNAMIC }
}
Column(
Modifier

View file

@ -27,8 +27,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.Theme
import ua.acclorite.book_story.ui.colorScheme
import ua.acclorite.book_story.util.UIText
@ -83,7 +85,7 @@ fun AppearanceSettingsThemeSwitcherItem(
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
contentDescription = stringResource(id = R.string.selected_content_desc),
modifier = Modifier
.size(26.dp),
tint =

View file

@ -1,8 +1,9 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.general
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
@ -18,8 +19,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.ChipItem
import ua.acclorite.book_story.presentation.components.GoBackButton
@ -42,7 +43,6 @@ fun GeneralSettings(
listState.canScrollForward
}
)
val context = LocalContext.current as ComponentActivity
val language = mainViewModel.language.collectAsState().value!!
@ -84,16 +84,17 @@ fun GeneralSettings(
MaterialTheme.typography.labelLarge,
it.first == language
)
}
}.sortedBy { it.title }
) {
mainViewModel.onEvent(
MainEvent.OnChangeLanguage(
it.id,
context
it.id
)
)
}
}
item { Spacer(modifier = Modifier.height(48.dp)) }
}
}
}

View file

@ -212,6 +212,8 @@ fun ReaderSettings(
)
}
}
item { Spacer(modifier = Modifier.height(48.dp)) }
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -21,7 +21,7 @@
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
Нам потрібен дозвіл на сховище, щоб просканувати вашу теку Завантажень.
Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг.
Без цього дозволу ви не зможете додати книгу.
</string>
<string name="add_books_description">
@ -50,14 +50,11 @@
<!-- Errors -->
<string name="error_permission">
Нам потрібен дозвіл на сховище, щоб просканувати вашу теку Завантажень та отримати книги
Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг
</string>
<string name="error_no_description">
Немає опису.
</string>
<string name="error_file_not_found">
Файл не знайдено. Будь ласка, перевірте чи файл цієї книги все ще існує.
</string>
<string name="error_something_went_wrong">
Щось пішло не так. Будь ласка, перевірте чи файл цієї книги не пошкоджений.
</string>
@ -186,9 +183,9 @@
<!-- Toast messages -->
<string name="press_again_toast">Натисніть ще раз, щоб вийти</string>
<string name="books_added">Всі книги були успішно додані.</string>
<string name="books_moved">Всі книги були успішно переміщені.</string>
<string name="books_deleted">Всі книги були успішно видалені.</string>
<string name="books_added">Всі обрані книги були успішно додані.</string>
<string name="books_moved">Всі обрані книги були успішно переміщені.</string>
<string name="books_deleted">Всі обрані книги були успішно видалені.</string>
<string name="cover_image_changed">Обкладинка була успішно змінена.</string>
<string name="cover_image_deleted">Обкладинка була успішно видалена.</string>
<string name="title_changed">Назва була успішно змінена.</string>
@ -236,5 +233,23 @@
<string name="go_back_content_desc">Назад</string>
<string name="cover_image_content_desc">Обкладинка</string>
<string name="open_reader_settings_content_desc">Відкрити налаштування читача</string>
<string name="background_anim_content_desc">Анімація фону</string>
<string name="outline_anim_content_desc">Анімація контуру</string>
<string name="top_app_bar_anim_content_desc">Анімація верхньої панелі</string>
<string name="file_icon_content_desc">Файл</string>
<string name="checkbox_content_desc">Галочка</string>
<string name="show_dropdown_content_desc">Показати випадаючий список</string>
<string name="cover_image_not_found_content_desc">Обкладенка не знайдена</string>
<string name="apply_changes_content_desc">Застосувати зміни</string>
<string name="error_content_desc">Помилка</string>
<string name="search_content_desc">Пошук</string>
<string name="clear_selected_items_content_desc">Очистити вибрані предмети</string>
<string name="add_files_content_desc">Додати файли</string>
<string name="exit_search_content_desc">Вийти з пошуку</string>
<string name="delete_whole_history_content_desc">Видалити всю історію</string>
<string name="move_books_content_desc">Перемістити книгу в іншу категорію</string>
<string name="delete_books_content_desc">Видалити обрані книги</string>
<string name="revert_content_desc">Повернути</string>
<string name="selected_content_desc">Вибрано</string>
</resources>

View file

@ -1,6 +1,6 @@
<resources>
<!-- Basic Strings -->
<string name="app_version" translatable="false">0.9.0</string>
<string name="app_version" translatable="false">0.9.2</string>
<string name="app_name">Book\'s Story</string>
<!-- Screens -->
@ -22,7 +22,7 @@
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
We need storage permission to scan your Downloads folder.
We need storage permission to scan Your Device for books.
Without this permission, you will not be able to add a book.
</string>
<string name="add_books_description">
@ -51,14 +51,11 @@
<!-- Errors -->
<string name="error_permission">
We need storage permission to scan your Downloads directory and get books
We need storage permission to scan Your Device for books
</string>
<string name="error_no_description">
No description.
</string>
<string name="error_file_not_found">
File was not found. Please check whether this book\'s file still exists.
</string>
<string name="error_something_went_wrong">
Something went wrong. Please check whether this book\'s file is not corrupted.
</string>
@ -186,9 +183,9 @@
<!-- Toast messages -->
<string name="press_again_toast">Press back again to exit</string>
<string name="books_added">All books were successfully added.</string>
<string name="books_moved">All books were successfully moved.</string>
<string name="books_deleted">All books were successfully deleted.</string>
<string name="books_added">All selected books were successfully added.</string>
<string name="books_moved">All selected books were successfully moved.</string>
<string name="books_deleted">All selected books were successfully deleted.</string>
<string name="cover_image_changed">Cover Image was successfully changed.</string>
<string name="cover_image_deleted">Cover Image was successfully deleted.</string>
<string name="title_changed">Title was successfully changed.</string>
@ -236,5 +233,23 @@
<string name="go_back_content_desc">Go back</string>
<string name="cover_image_content_desc">Cover image</string>
<string name="open_reader_settings_content_desc">Open reader settings</string>
<string name="background_anim_content_desc">Background animation</string>
<string name="outline_anim_content_desc">Outline animation</string>
<string name="top_app_bar_anim_content_desc">Top app bar animation</string>
<string name="file_icon_content_desc">File</string>
<string name="checkbox_content_desc">Checkbox</string>
<string name="show_dropdown_content_desc">Show dropdown</string>
<string name="cover_image_not_found_content_desc">Cover image not found</string>
<string name="apply_changes_content_desc">Apply changes</string>
<string name="error_content_desc">Error</string>
<string name="search_content_desc">Search</string>
<string name="clear_selected_items_content_desc">Clear selected items</string>
<string name="add_files_content_desc">Add files</string>
<string name="exit_search_content_desc">Exit search</string>
<string name="delete_whole_history_content_desc">Delete whole history</string>
<string name="move_books_content_desc">Move books to another category</string>
<string name="delete_books_content_desc">Delete selected books</string>
<string name="revert_content_desc">Revert</string>
<string name="selected_content_desc">Selected</string>
</resources>