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 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "0.9.0" versionName = "0.9.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -139,4 +139,6 @@ dependencies {
implementation("androidx.appcompat:appcompat:latest.release") implementation("androidx.appcompat:appcompat:latest.release")
implementation("androidx.appcompat:appcompat-resources: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 <activity
android:name=".Activity" android:name=".Activity"
android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Start.Splash"> android:theme="@style/Theme.Start.Splash">

View file

@ -1,15 +1,13 @@
package ua.acclorite.book_story package ua.acclorite.book_story
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.res.Configuration
import android.database.CursorWindow import android.database.CursorWindow
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.domain.model.Book
import ua.acclorite.book_story.presentation.components.bottom_navigation_bar.BottomNavigationBar 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.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.MainViewModel
import ua.acclorite.book_story.presentation.data.NavigationHost import ua.acclorite.book_story.presentation.data.NavigationHost
import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.data.Screen
@ -55,7 +52,7 @@ import java.lang.reflect.Field
@SuppressLint("DiscouragedPrivateApi") @SuppressLint("DiscouragedPrivateApi")
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
class Activity : ComponentActivity() { class Activity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private val libraryViewModel: LibraryViewModel by viewModels() private val libraryViewModel: LibraryViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels() private val historyViewModel: HistoryViewModel by viewModels()
@ -75,10 +72,7 @@ class Activity : ComponentActivity() {
} }
// Initializing all variables // Initializing all variables
mainViewModel.init( mainViewModel.init(libraryViewModel)
activity = this,
libraryViewModel,
)
// Splash screen // Splash screen
installSplashScreen().apply { installSplashScreen().apply {
@ -93,7 +87,6 @@ class Activity : ComponentActivity() {
setContent { setContent {
val windowClass = calculateWindowSizeClass(activity = this) val windowClass = calculateWindowSizeClass(activity = this)
val updating = mainViewModel.updating.collectAsState().value
val theme = mainViewModel.theme.collectAsState().value ?: Theme.BLUE val theme = mainViewModel.theme.collectAsState().value ?: Theme.BLUE
val darkTheme = val darkTheme =
@ -104,157 +97,142 @@ class Activity : ComponentActivity() {
theme = theme, theme = theme,
isDark = darkTheme.isDark() isDark = darkTheme.isDark()
) { ) {
if (!updating) { NavigationHost(startScreen = Screen.LIBRARY) {
NavigationHost(startScreen = Screen.LIBRARY) { val currentScreen by this.getCurrentScreen().collectAsState()
val currentScreen by this.getCurrentScreen().collectAsState()
AnimatedVisibility( AnimatedVisibility(
visible = currentScreen == Screen.LIBRARY || visible = currentScreen == Screen.LIBRARY ||
currentScreen == Screen.HISTORY || currentScreen == Screen.HISTORY ||
currentScreen == Screen.BROWSE, currentScreen == Screen.BROWSE,
enter = Transitions.BackSlidingTransitionIn, enter = Transitions.BackSlidingTransitionIn,
exit = Transitions.SlidingTransitionOut exit = Transitions.SlidingTransitionOut
) {
Scaffold(
bottomBar = {
if (!tabletUI) {
BottomNavigationBar(navigator = this@NavigationHost)
}
},
containerColor = MaterialTheme.colorScheme.surface
) { ) {
Scaffold( Box(
bottomBar = { modifier = Modifier
if (!tabletUI) { .fillMaxSize()
BottomNavigationBar(navigator = this@NavigationHost) .padding(
} start = if (tabletUI) 80.dp else 0.dp,
}, bottom = it.calculateBottomPadding()
containerColor = MaterialTheme.colorScheme.surface )
) { ) {
Box( composable(screen = Screen.LIBRARY) {
modifier = Modifier @Suppress("UNCHECKED_CAST")
.fillMaxSize() LibraryScreen(
.padding( viewModel = libraryViewModel,
start = if (tabletUI) 80.dp else 0.dp, historyViewModel = historyViewModel,
bottom = it.calculateBottomPadding() browseViewModel = browseViewModel,
) navigator = this@NavigationHost,
) { addedBooks = retrieveArgument("added_books") as? List<Book>
composable(screen = Screen.LIBRARY) { ?: emptyList()
@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
)
}
} }
if (tabletUI) { composable(screen = Screen.HISTORY) {
CustomNavigationRail(navigator = this@NavigationHost) HistoryScreen(
viewModel = historyViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
composable(screen = Screen.BROWSE) {
BrowseScreen(
viewModel = browseViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
} }
} }
}
// Book Info if (tabletUI) {
composable( CustomNavigationRail(navigator = this@NavigationHost)
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
)
} }
}
// Settings // Book Info
composable( composable(
screen = Screen.SETTINGS, screen = Screen.BOOK_INFO,
enterAnim = Transitions.SlidingTransitionIn, enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
SettingsScreen( BookInfoScreen(
navigator = this@NavigationHost 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 // Settings
composable( composable(
screen = Screen.GENERAL_SETTINGS, screen = Screen.SETTINGS,
enterAnim = Transitions.SlidingTransitionIn, enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
GeneralSettings( SettingsScreen(
mainViewModel = mainViewModel, navigator = this@NavigationHost
navigator = this@NavigationHost )
) }
}
composable( // Nested categories
screen = Screen.APPEARANCE_SETTINGS, composable(
enterAnim = Transitions.SlidingTransitionIn, screen = Screen.GENERAL_SETTINGS,
exitAnim = Transitions.SlidingTransitionOut enterAnim = Transitions.SlidingTransitionIn,
) { exitAnim = Transitions.SlidingTransitionOut
AppearanceSettings( ) {
mainViewModel = mainViewModel, GeneralSettings(
navigator = this@NavigationHost mainViewModel = mainViewModel,
) navigator = this@NavigationHost
} )
composable( }
screen = Screen.READER_SETTINGS, composable(
enterAnim = Transitions.SlidingTransitionIn, screen = Screen.APPEARANCE_SETTINGS,
exitAnim = Transitions.SlidingTransitionOut enterAnim = Transitions.SlidingTransitionIn,
) { exitAnim = Transitions.SlidingTransitionOut
ReaderSettings( ) {
mainViewModel = mainViewModel, AppearanceSettings(
navigator = this@NavigationHost 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) // Start screen (later)
// composable(screen = Screen.START) { // composable(screen = Screen.START) {
// StartScreen() // 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.R
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.UIText import ua.acclorite.book_story.util.UIText
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@ -22,12 +21,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
val stream = ByteArrayOutputStream() val stream = ByteArrayOutputStream()
book.coverImage?.asAndroidBitmap()?.compress( book.coverImage?.asAndroidBitmap()?.compress(
if (legacyAPI) Bitmap.CompressFormat.WEBP if (legacyAPI) Bitmap.CompressFormat.WEBP
else Bitmap.CompressFormat.WEBP_LOSSLESS, else Bitmap.CompressFormat.WEBP_LOSSY,
if (legacyAPI) 30 else 100, 0,
stream stream
) )
val text = book.text.joinToString("\n") { val textAsString = book.text.joinToString("\n") {
it.line.trim() it.line.trim()
} }
@ -37,7 +36,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
filePath = book.filePath, filePath = book.filePath,
progress = book.progress, progress = book.progress,
author = book.author.string, author = book.author.string,
text = text, text = textAsString,
description = book.description, description = book.description,
image = stream.toByteArray(), image = stream.toByteArray(),
category = book.category category = book.category
@ -64,7 +63,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
description = bookEntity.description, description = bookEntity.description,
progress = bookEntity.progress, progress = bookEntity.progress,
file = if (file.exists()) file else null, file = if (file.exists()) file else null,
text = bookEntity.text.split("\n").map { StringWithId(it) }, text = emptyList(),
filePath = bookEntity.filePath, filePath = bookEntity.filePath,
lastOpened = null, lastOpened = null,
category = bookEntity.category, category = bookEntity.category,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,11 +6,11 @@ import ua.acclorite.book_story.util.Resource
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
class GetFilesFromDownloads @Inject constructor( class GetFilesFromDevice @Inject constructor(
private val repository: BookRepository private val repository: BookRepository
) { ) {
suspend fun execute(query: String): Flow<Resource<List<File>>> { 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 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.domain.repository.BookRepository
import ua.acclorite.book_story.util.Resource
import java.io.File
import javax.inject.Inject import javax.inject.Inject
class GetText @Inject constructor(private val repository: BookRepository) { class GetText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(file: File): Flow<Resource<List<StringWithId>>> { suspend fun execute(id: Int): String {
return repository.getBookTextFromFile(file) 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 package ua.acclorite.book_story.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@ -22,8 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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 import ua.acclorite.book_story.ui.elevation
/** /**
@ -38,7 +42,7 @@ import ua.acclorite.book_story.ui.elevation
@Composable @Composable
fun AnimatedTopAppBar( fun AnimatedTopAppBar(
containerColor: Color = MaterialTheme.colorScheme.surface, containerColor: Color = MaterialTheme.colorScheme.surface,
scrolledContainerColor: Color? = MaterialTheme.elevation(), scrolledContainerColor: Color = MaterialTheme.elevation(),
scrollBehavior: TopAppBarScrollBehavior?, scrollBehavior: TopAppBarScrollBehavior?,
isTopBarScrolled: Boolean?, isTopBarScrolled: Boolean?,
@ -58,79 +62,90 @@ fun AnimatedTopAppBar(
content3Title: @Composable () -> Unit = {}, content3Title: @Composable () -> Unit = {},
content3Actions: @Composable RowScope.() -> Unit = {} content3Actions: @Composable RowScope.() -> Unit = {}
) { ) {
//todo fix lags.
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
if ((scrollBehavior != null || isTopBarScrolled != null) && scrolledContainerColor != null) { val density = LocalDensity.current
val statusBarPadding = with(density) {
val fraction by remember { WindowInsets.statusBars.getTop(density).toDp()
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()
}
if (containerColor != Color.Transparent) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(64.dp + statusBarPadding) .height(64.dp + statusBarPadding)
.background( .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( TopAppBar(
navigationIcon = content1NavigationIcon, navigationIcon = content1NavigationIcon,
title = content1Title, title = content1Title,
actions = content1Actions, actions = content1Actions,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = animatedContainerColor,
scrolledContainerColor = Color.Transparent scrolledContainerColor = scrolledContainerColor
) )
) )
} }
if (content2Visibility != null) { if (content2Visibility != null) {
DefaultTransition(visible = content2Visibility) { AnimatedVisibility(
visible = content2Visibility,
enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
) {
TopAppBar( TopAppBar(
navigationIcon = content2NavigationIcon, navigationIcon = content2NavigationIcon,
title = content2Title, title = content2Title,
actions = content2Actions, actions = content2Actions,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = animatedContainerColor,
scrolledContainerColor = Color.Transparent scrolledContainerColor = scrolledContainerColor
) )
) )
} }
} }
if (content3Visibility != null) { if (content3Visibility != null) {
DefaultTransition(visible = content3Visibility) { AnimatedVisibility(
visible = content3Visibility,
enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
) {
TopAppBar( TopAppBar(
navigationIcon = content3NavigationIcon, navigationIcon = content3NavigationIcon,
title = content3Title, title = content3Title,
actions = content3Actions, actions = content3Actions,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = animatedContainerColor,
scrolledContainerColor = Color.Transparent scrolledContainerColor = scrolledContainerColor
) )
) )
} }

View file

@ -13,14 +13,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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 androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/** /**
* Custom Checkbox. Has a Circle shape. * Custom Checkbox. Has a Circle shape.
*/ */
@Composable @Composable
fun CustomCheckbox(selected: Boolean) { fun CustomCheckbox(selected: Boolean, size: Dp = 22.dp) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
tint = if (selected) MaterialTheme.elevation(elevation = 2.dp) else Color.Transparent, tint = if (selected) MaterialTheme.elevation(elevation = 2.dp) else Color.Transparent,
@ -36,7 +39,7 @@ fun CustomCheckbox(selected: Boolean) {
shape = CircleShape shape = CircleShape
) )
.padding(4.dp) .padding(4.dp)
.size(22.dp), .size(size),
contentDescription = "checkbox" 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 context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
override var status: TextToolbarStatus by mutableStateOf(TextToolbarStatus.Hidden) override var status: TextToolbarStatus by mutableStateOf(TextToolbarStatus.Hidden)
private set
override fun showMenu( override fun showMenu(
rect: Rect, 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.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 androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.data.Navigator import ua.acclorite.book_story.presentation.data.Navigator
@ -19,20 +12,12 @@ import ua.acclorite.book_story.presentation.data.Navigator
*/ */
@Composable @Composable
fun GoBackButton(navigator: Navigator, customOnClick: () -> Unit = {}) { fun GoBackButton(navigator: Navigator, customOnClick: () -> Unit = {}) {
var isClicked by remember { mutableStateOf(false) } CustomIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
IconButton( contentDescription = stringResource(id = R.string.go_back_content_desc),
enabled = !isClicked, disableOnClick = true
onClick = {
isClicked = true
navigator.navigateBack()
customOnClick()
}
) { ) {
Icon( navigator.navigateBack()
imageVector = Icons.AutoMirrored.Filled.ArrowBack, customOnClick()
contentDescription = stringResource(id = R.string.go_back_content_desc),
tint = MaterialTheme.colorScheme.onSurface
)
} }
} }

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -23,6 +24,11 @@ fun RowScope.BottomNavigationBarItem(
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val icon = remember(isSelected) {
if (isSelected) item.selectedIcon
else item.unselectedIcon
}
NavigationBarItem( NavigationBarItem(
label = { label = {
Text( Text(
@ -37,9 +43,7 @@ fun RowScope.BottomNavigationBarItem(
onClick = { onClick() }, onClick = { onClick() },
icon = { icon = {
Icon( Icon(
painter = painter = icon,
if (isSelected) item.selectedIcon
else item.unselectedIcon,
contentDescription = item.title, contentDescription = item.title,
modifier = Modifier.size(24.dp) 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -69,6 +73,7 @@ fun CustomDialogWithContent(
withDivider: Boolean, withDivider: Boolean,
customContent: @Composable (ColumnScope.() -> Unit) = {} customContent: @Composable (ColumnScope.() -> Unit) = {}
) { ) {
var actionClicked by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
properties = properties properties = properties
@ -151,7 +156,13 @@ fun CustomDialogWithContent(
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
) { ) {
TextButton(onClick = { onDismiss() }) { TextButton(
onClick = {
actionClicked = true
onDismiss()
},
enabled = !actionClicked
) {
Text( Text(
text = stringResource(id = R.string.cancel), text = stringResource(id = R.string.cancel),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
@ -161,8 +172,11 @@ fun CustomDialogWithContent(
if (actionText != null) { if (actionText != null) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
TextButton( TextButton(
onClick = { onAction() }, onClick = {
enabled = isActionEnabled == true actionClicked = true
onAction()
},
enabled = isActionEnabled == true && !actionClicked
) { ) {
Text( Text(
text = actionText, text = actionText,

View file

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

View file

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

View file

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

View file

@ -1,14 +1,12 @@
package ua.acclorite.book_story.presentation.data package ua.acclorite.book_story.presentation.data
import android.os.Build import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -44,12 +42,16 @@ class MainViewModel @Inject constructor(
private val _isReady = MutableStateFlow(false) private val _isReady = MutableStateFlow(false)
val isReady = _isReady.asStateFlow() val isReady = _isReady.asStateFlow()
private val _updating = MutableStateFlow(false)
val updating = _updating.asStateFlow()
/* -- Language ----------------------------------------------------- */ /* -- Language ----------------------------------------------------- */
private var _language: String = 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) { set(value) {
field = value field = value
stateHandle[Constants.LANGUAGE] = value stateHandle[Constants.LANGUAGE] = value
@ -172,23 +174,9 @@ class MainViewModel @Inject constructor(
fun onEvent(event: MainEvent) { fun onEvent(event: MainEvent) {
when (event) { when (event) {
is MainEvent.OnChangeLanguage -> { is MainEvent.OnChangeLanguage -> {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.Main) {
_updating.update { true } changeLanguage.execute(event.lang)
changeLanguage.execute(event.lang, event.activity)
_language = 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( fun init(
activity: ComponentActivity,
libraryViewModel: LibraryViewModel, libraryViewModel: LibraryViewModel,
) { ) {
val isViewModelsReady = combine( val isViewModelsReady = combine(
@ -311,11 +298,11 @@ class MainViewModel @Inject constructor(
} }
// Language // Language
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.Main) {
getDatastore getDatastore
.execute(DataStoreConstants.LANGUAGE, _language) .execute(DataStoreConstants.LANGUAGE, _language)
.first { .first {
onEvent(MainEvent.OnChangeLanguage(it, activity)) onEvent(MainEvent.OnChangeLanguage(it))
it.isNotBlank() 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.Done
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.pullrefresh.PullRefreshDefaults
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -34,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState 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.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar 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.CustomSnackbar
import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.data.Argument import ua.acclorite.book_story.presentation.data.Argument
@ -114,8 +113,6 @@ fun BookInfoScreen(
val snackbarState = remember { SnackbarHostState() } val snackbarState = remember { SnackbarHostState() }
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
refreshThreshold = PullRefreshDefaults.RefreshThreshold + 32.dp,
refreshingOffset = PullRefreshDefaults.RefreshingOffset + 64.dp,
onRefresh = { onRefresh = {
viewModel.onEvent( viewModel.onEvent(
BookInfoEvent.OnUpdateBook( BookInfoEvent.OnUpdateBook(
@ -193,26 +190,21 @@ fun BookInfoScreen(
} }
}, },
content1Actions = { content1Actions = {
IconButton( CustomIconButton(
enabled = !state.isRefreshing, icon = Icons.Default.Refresh,
onClick = { contentDescription = stringResource(id = R.string.refresh_book_content_desc),
viewModel.onEvent( disableOnClick = false,
BookInfoEvent.OnUpdateBook( enabled = !state.isRefreshing
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
)
}
) { ) {
Icon( viewModel.onEvent(
imageVector = Icons.Default.Refresh, BookInfoEvent.OnUpdateBook(
contentDescription = stringResource(id = R.string.refresh_book_content_desc), refreshList = {
modifier = Modifier.size(24.dp), libraryViewModel.onEvent(LibraryEvent.OnLoadList)
tint = MaterialTheme.colorScheme.onSurfaceVariant historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
) )
} }
@ -230,34 +222,30 @@ fun BookInfoScreen(
enter = Transitions.DefaultTransitionIn, enter = Transitions.DefaultTransitionIn,
exit = fadeOut(tween(200)) 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() && enabled = state.titleValue.isNotBlank() &&
state.titleValue.trim() != state.book.title.trim(), state.titleValue.trim() !=
onClick = { state.book.title.trim(),
viewModel.onEvent(BookInfoEvent.OnUpdateTitle( color = if (state.titleValue.isNotBlank()
refreshList = { && state.titleValue != state.book.title
libraryViewModel.onEvent( ) MaterialTheme.colorScheme.onSurface
LibraryEvent.OnLoadList else MaterialTheme.colorScheme.onSurfaceVariant
)
}
))
Toast.makeText(
context,
context.getString(R.string.title_changed),
Toast.LENGTH_LONG
).show()
}
) { ) {
Icon( viewModel.onEvent(BookInfoEvent.OnUpdateTitle(
imageVector = Icons.Default.Done, refreshList = {
contentDescription = "Apply changes", libraryViewModel.onEvent(
tint = LibraryEvent.OnLoadList
if (state.titleValue.isNotBlank() )
&& state.titleValue != state.book.title }
) ))
MaterialTheme.colorScheme.onSurface Toast.makeText(
else MaterialTheme.colorScheme.onSurfaceVariant context,
) context.getString(R.string.title_changed),
Toast.LENGTH_LONG
).show()
} }
} }
} }
@ -354,7 +342,9 @@ fun BookInfoScreen(
PullRefreshIndicator( PullRefreshIndicator(
state.isRefreshing, state.isRefreshing,
refreshState, refreshState,
Modifier.align(Alignment.TopCenter), Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding()),
backgroundColor = MaterialTheme.colorScheme.inverseSurface, backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface 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.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book 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.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
@ -76,7 +78,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
) { ) {
Icon( Icon(
imageVector = Icons.Default.Image, imageVector = Icons.Default.Image,
contentDescription = "Cover Image not found", contentDescription = stringResource(id = R.string.cover_image_not_found_content_desc),
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
@ -87,7 +89,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
if (book.coverImage != null) { if (book.coverImage != null) {
Image( Image(
bitmap = book.coverImage, bitmap = book.coverImage,
contentDescription = "Cover", contentDescription = stringResource(id = R.string.cover_image_content_desc),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(MaterialTheme.shapes.large), .clip(MaterialTheme.shapes.large),

View file

@ -1,26 +1,22 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.DropdownMenu 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.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomDropDownMenuItem 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.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel 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() val state by viewModel.state.collectAsState()
Box { 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) viewModel.onEvent(BookInfoEvent.OnShowHideMoreDropDown)
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Show drop down",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
DropdownMenu( DropdownMenu(

View file

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

View file

@ -6,8 +6,10 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HideImage import androidx.compose.material.icons.filled.HideImage
import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.ImageSearch
@ -44,7 +46,8 @@ fun BookInfoChangeCoverBottomSheet(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val book = state.book val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val photoPicker = rememberLauncherForActivityResult( val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(), contract = ActivityResultContracts.PickVisualMedia(),
@ -89,7 +92,7 @@ fun BookInfoChangeCoverBottomSheet(
) )
} }
if (book.coverImage != null) { if (state.book.coverImage != null) {
BookInfoChangeCoverBottomSheetItem( BookInfoChangeCoverBottomSheetItem(
icon = Icons.Default.HideImage, icon = Icons.Default.HideImage,
text = stringResource(id = R.string.delete_cover), 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 android.widget.Toast
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
@ -12,6 +14,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -35,20 +38,32 @@ fun BookInfoDetailsBottomSheet(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val pattern = SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) val pattern = remember {
val lastOpened = pattern.format(Date(state.book.lastOpened ?: 0)) 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 fileSizeKB = remember {
val fileSizeMB = if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 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) if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB) else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "" else ""
}
ModalBottomSheet( ModalBottomSheet(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -129,7 +144,10 @@ fun BookInfoDetailsBottomSheet(
} }
} }
Spacer(
Spacer(modifier = Modifier.height(48.dp)) modifier = Modifier.height(
8.dp + navigationBarPadding
)
)
} }
} }

View file

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

View file

@ -11,14 +11,10 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.items
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.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -38,14 +32,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -55,11 +49,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState 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.R
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar 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.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.components.is_messages.IsError import ua.acclorite.book_story.presentation.components.is_messages.IsError
@ -79,7 +71,7 @@ import ua.acclorite.book_story.ui.elevation
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalFoundationApi::class, ExperimentalFoundationApi::class,
ExperimentalMaterialApi::class, FlowPreview::class ExperimentalMaterialApi::class
) )
@Composable @Composable
fun BrowseScreen( fun BrowseScreen(
@ -98,34 +90,21 @@ fun BrowseScreen(
} }
) )
val focusRequester = remember { FocusRequester() } 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) { LaunchedEffect(Unit) {
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState)) viewModel.onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
} }
if (state.requestPermissionDialog) { if (state.requestPermissionDialog) {
BrowseStoragePermissionDialog(viewModel, permissionState) BrowseStoragePermissionDialog(viewModel, permissionState) {
showErrorMessage = it
}
} }
if (state.showAddingDialog) { if (state.showAddingDialog) {
BrowseAddingDialog( BrowseAddingDialog(
@ -146,7 +125,7 @@ fun BrowseScreen(
scrolledContainerColor = MaterialTheme.elevation(), scrolledContainerColor = MaterialTheme.elevation(),
scrollBehavior = null, scrollBehavior = null,
isTopBarScrolled = state.scrollIndex > 0 || state.scrollOffset > 0 || state.hasSelectedItems, isTopBarScrolled = state.hasSelectedItems || state.listState.canScrollBackward,
content1Visibility = !state.hasSelectedItems && !state.showSearch, content1Visibility = !state.hasSelectedItems && !state.showSearch,
content1NavigationIcon = {}, content1NavigationIcon = {},
@ -160,26 +139,26 @@ fun BrowseScreen(
) )
}, },
content1Actions = { content1Actions = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) { CustomIconButton(
Icon( icon = Icons.Default.Search,
imageVector = Icons.Default.Search, contentDescription = stringResource(id = R.string.search_content_desc),
contentDescription = "Search files", disableOnClick = false,
modifier = Modifier.size(24.dp), enabled = !state.showSearch
tint = MaterialTheme.colorScheme.onSurfaceVariant ) {
) viewModel.onEvent(BrowseEvent.OnSearchShowHide)
} }
MoreDropDown(navigator = navigator) MoreDropDown(navigator = navigator)
}, },
content2Visibility = state.hasSelectedItems, content2Visibility = state.hasSelectedItems,
content2NavigationIcon = { content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnClearSelectedFiles) }) { CustomIconButton(
Icon( icon = Icons.Default.Clear,
imageVector = Icons.Default.Clear, contentDescription =
contentDescription = "Clear selected items", stringResource(id = R.string.clear_selected_items_content_desc),
modifier = Modifier.size(24.dp), disableOnClick = true
tint = MaterialTheme.colorScheme.onSurface ) {
) viewModel.onEvent(BrowseEvent.OnClearSelectedFiles)
} }
}, },
content2Title = { content2Title = {
@ -195,25 +174,27 @@ fun BrowseScreen(
) )
}, },
content2Actions = { content2Actions = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnAddingDialogRequest) }) { CustomIconButton(
Icon( icon = Icons.Default.Check,
imageVector = Icons.Default.Check, contentDescription =
contentDescription = "Add files to library", stringResource(id = R.string.add_files_content_desc),
modifier = Modifier.size(24.dp), disableOnClick = false,
tint = MaterialTheme.colorScheme.onSurfaceVariant enabled = !state.showAddingDialog
) ) {
viewModel.onEvent(BrowseEvent.OnAddingDialogRequest)
} }
}, },
content3Visibility = state.showSearch && !state.hasSelectedItems, content3Visibility = state.showSearch && !state.hasSelectedItems,
content3NavigationIcon = { content3NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) { CustomIconButton(
Icon( icon = Icons.AutoMirrored.Default.ArrowBack,
imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = stringResource(
contentDescription = "Exit search mode", id = R.string.exit_search_content_desc
modifier = Modifier.size(24.dp), ),
tint = MaterialTheme.colorScheme.onSurface disableOnClick = true
) ) {
viewModel.onEvent(BrowseEvent.OnSearchShowHide)
} }
}, },
content3Title = { content3Title = {
@ -268,52 +249,54 @@ fun BrowseScreen(
.fillMaxSize() .fillMaxSize()
.padding(top = padding.calculateTopPadding()) .padding(top = padding.calculateTopPadding())
) { ) {
LazyVerticalGrid( DefaultTransition(visible = !state.isLoading) {
state = listState, LazyColumn(
modifier = Modifier state = state.listState,
.fillMaxSize(), modifier = Modifier
columns = GridCells.Adaptive(170.dp), .fillMaxSize(),
contentPadding = PaddingValues(12.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
items( items(
state.selectableFiles, state.selectableFiles,
key = { it.first.path } key = { it.first.path }
) { selectableFile -> ) { selectableFile ->
BrowseFileItem( BrowseFileItem(
file = selectableFile, file = selectableFile,
modifier = Modifier modifier = Modifier
.animateItemPlacement(), .animateItemPlacement(),
onClick = { hasSelectedFiles = state.selectableFiles.any { it.second },
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile)) onClick = {
} viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
) }
)
}
} }
} }
if (state.isLoading && !state.isRefreshing && state.selectableFiles.isEmpty()) { AnimatedVisibility(
CircularProgressIndicator( visible = showErrorMessage,
color = MaterialTheme.colorScheme.primary, modifier = Modifier.align(Alignment.Center),
strokeCap = StrokeCap.Round, enter = Transitions.DefaultTransitionIn,
modifier = Modifier exit = fadeOut(tween(0))
.align(Alignment.Center) ) {
.size(36.dp)
)
}
DefaultTransition(visible = state.showErrorMessage, Modifier.align(Alignment.Center)) {
IsError( IsError(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
errorMessage = stringResource(id = R.string.error_permission), errorMessage = stringResource(id = R.string.error_permission),
icon = painterResource(id = R.drawable.error), icon = painterResource(id = R.drawable.error),
actionTitle = stringResource(id = R.string.grant_permission) actionTitle = stringResource(id = R.string.grant_permission)
) { ) {
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState)) viewModel.onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = !state.isLoading && state.selectableFiles.isEmpty() visible = !state.isLoading && state.selectableFiles.isEmpty()
&& !state.showErrorMessage && !state.requestPermissionDialog && !showErrorMessage && !state.requestPermissionDialog
&& !state.isRefreshing, && !state.isRefreshing,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn, 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.ui.DefaultTransition import ua.acclorite.book_story.ui.DefaultTransition
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -41,19 +44,24 @@ import java.util.Locale
* Browse list element item. Can be selected. * Browse list element item. Can be selected.
*/ */
@Composable @Composable
fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) { fun BrowseFileItem(
val fileExtension: String = file.first.name.substringAfterLast(".", "") 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 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 lastModified = pattern.format(Date(file.first.lastModified())) val fileSize = remember {
val icon = when (fileExtension) { if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
"txt" -> painterResource(id = R.drawable.txt) else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
"epub" -> painterResource(id = R.drawable.epub) else "0 KB"
"pdf" -> painterResource(id = R.drawable.pdf)
else -> painterResource(id = R.drawable.file)
} }
val outlineColor = if (file.second) MaterialTheme.colorScheme.primary
val outlineColor = if (file.second) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant else MaterialTheme.colorScheme.outlineVariant
val backgroundColor = if (file.second) MaterialTheme.colorScheme.secondaryContainer val backgroundColor = if (file.second) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent else Color.Transparent
@ -61,90 +69,82 @@ fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () ->
val animatedOutlineColor by animateColorAsState( val animatedOutlineColor by animateColorAsState(
targetValue = outlineColor, targetValue = outlineColor,
tween(300), tween(300),
label = "Outline animation" label = stringResource(id = R.string.outline_anim_content_desc)
) )
val animatedBackgroundColor by animateColorAsState( val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor, targetValue = backgroundColor,
tween(300), tween(300),
label = "Background animation" label = stringResource(id = R.string.background_anim_content_desc)
) )
Column( Row(
verticalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
.padding(6.dp) .fillMaxWidth()
.clip(MaterialTheme.shapes.medium) .padding(horizontal = 8.dp, vertical = 3.dp)
.border( .clip(RoundedCornerShape(10.dp))
width = 1.dp,
color = animatedOutlineColor,
shape = MaterialTheme.shapes.medium
)
.padding(1.dp)
.background(animatedBackgroundColor) .background(animatedBackgroundColor)
.clickable { .clickable {
onClick() 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start, modifier = Modifier.weight(0.88f)
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 10.dp, vertical = 12.dp)
) { ) {
Box { Box(
DefaultTransition(visible = !file.second) { modifier = Modifier
Icon( .border(
painter = painterResource(R.drawable.file), 1.dp,
contentDescription = "File", animatedOutlineColor,
modifier = Modifier RoundedCornerShape(6.dp)
.size(28.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} .padding(14.dp),
DefaultTransition(visible = file.second) { contentAlignment = Alignment.Center
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
) { ) {
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( Text(
file.first.name, file.first.name,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 2,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
lastModified, "$fileSize, $lastModified",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontSize = 11.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth() 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) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: PermissionState) { fun BrowseStoragePermissionDialog(
viewModel: BrowseViewModel,
permissionState: PermissionState,
showErrorMessage: (Boolean) -> Unit
) {
val activity = LocalContext.current as ComponentActivity val activity = LocalContext.current as ComponentActivity
CustomDialogWithContent( CustomDialogWithContent(
@ -27,15 +31,28 @@ fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: P
description = stringResource(id = R.string.storage_permission_description), description = stringResource(id = R.string.storage_permission_description),
actionText = stringResource(id = R.string.grant), actionText = stringResource(id = R.string.grant),
imageVectorIcon = Icons.Default.SdStorage, imageVectorIcon = Icons.Default.SdStorage,
onDismiss = { viewModel.onEvent(BrowseEvent.OnStoragePermissionDismiss(permissionState)) }, onDismiss = {
viewModel.onEvent(
BrowseEvent.OnStoragePermissionDismiss(
permissionState,
showErrorMessage = { showErrorMessage(true) }
)
)
},
isActionEnabled = true, isActionEnabled = true,
withDivider = false, withDivider = false,
onAction = { onAction = {
viewModel.onEvent( viewModel.onEvent(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BrowseEvent.OnStoragePermissionRequest(activity) BrowseEvent.OnStoragePermissionRequest(
activity,
hideErrorMessage = { showErrorMessage(false) }
)
} else { } else {
BrowseEvent.OnLegacyStoragePermissionRequest(permissionState) BrowseEvent.OnLegacyStoragePermissionRequest(
permissionState,
hideErrorMessage = { showErrorMessage(false) }
)
} }
) )
} }

View file

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

View file

@ -11,13 +11,24 @@ import ua.acclorite.book_story.presentation.data.Navigator
import java.io.File import java.io.File
sealed class BrowseEvent { sealed class BrowseEvent {
data class OnStoragePermissionRequest(val activity: ComponentActivity) : BrowseEvent() data class OnStoragePermissionRequest(
data class OnLegacyStoragePermissionRequest(val permissionState: PermissionState) : val activity: ComponentActivity, val hideErrorMessage: () -> Unit
BrowseEvent() ) : 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 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 OnSelectFile(val file: Pair<File, Boolean>) : BrowseEvent()
data class OnSelectBook(val book: NullableBook) : BrowseEvent() data class OnSelectBook(val book: NullableBook) : BrowseEvent()
data object OnSearchShowHide : BrowseEvent() data object OnSearchShowHide : BrowseEvent()
@ -29,8 +40,5 @@ sealed class BrowseEvent {
data object OnGetBooksFromFiles : BrowseEvent() data object OnGetBooksFromFiles : BrowseEvent()
data class OnAddBooks(val navigator: Navigator, val resetScroll: () -> Unit) : BrowseEvent() data class OnAddBooks(val navigator: Navigator, val resetScroll: () -> Unit) : BrowseEvent()
data object OnLoadList : 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 package ua.acclorite.book_story.presentation.screens.browse.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import java.io.File import java.io.File
@ -7,10 +8,11 @@ import java.io.File
@Immutable @Immutable
data class BrowseState( data class BrowseState(
val selectableFiles: List<Pair<File, Boolean>> = emptyList(), val selectableFiles: List<Pair<File, Boolean>> = emptyList(),
val listState: LazyListState = LazyListState(0, 0),
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val requestPermissionDialog: Boolean = false, val requestPermissionDialog: Boolean = false,
val showErrorMessage: Boolean = false,
val selectedItemsCount: Int = 0, val selectedItemsCount: Int = 0,
val hasSelectedItems: Boolean = false, val hasSelectedItems: Boolean = false,

View file

@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi 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.model.NullableBook
import ua.acclorite.book_story.domain.use_case.FastGetBooks 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.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.domain.use_case.InsertBooks
import ua.acclorite.book_story.presentation.data.Argument import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.data.Screen
@ -31,7 +32,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BrowseViewModel @Inject constructor( class BrowseViewModel @Inject constructor(
private val getBooksFromFiles: GetBooksFromFiles, private val getBooksFromFiles: GetBooksFromFiles,
private val getFilesFromDownloads: GetFilesFromDownloads, private val getFilesFromDevice: GetFilesFromDevice,
private val insertBooks: InsertBooks, private val insertBooks: InsertBooks,
private val fastGetBooks: FastGetBooks private val fastGetBooks: FastGetBooks
) : ViewModel() { ) : ViewModel() {
@ -65,10 +66,10 @@ class BrowseViewModel @Inject constructor(
} }
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = false, requestPermissionDialog = false
showErrorMessage = false
) )
} }
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList) onEvent(BrowseEvent.OnRefreshList)
break break
} }
@ -93,10 +94,10 @@ class BrowseViewModel @Inject constructor(
} }
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = false, requestPermissionDialog = false
showErrorMessage = false
) )
} }
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList) onEvent(BrowseEvent.OnRefreshList)
break break
} }
@ -112,8 +113,7 @@ class BrowseViewModel @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = false, requestPermissionDialog = false
showErrorMessage = !isPermissionGranted
) )
} }
@ -121,8 +121,9 @@ class BrowseViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
getFilesFromDownloads() getFilesFromDownloads()
} }
} else {
event.showErrorMessage()
} }
} }
is BrowseEvent.OnRefreshList -> { is BrowseEvent.OnRefreshList -> {
@ -131,7 +132,8 @@ class BrowseViewModel @Inject constructor(
it.copy( it.copy(
isRefreshing = true, isRefreshing = true,
hasSelectedItems = false, hasSelectedItems = false,
showSearch = false showSearch = false,
listState = LazyListState(0, 0)
) )
} }
@ -146,55 +148,72 @@ class BrowseViewModel @Inject constructor(
} }
is BrowseEvent.OnPermissionCheck -> { is BrowseEvent.OnPermissionCheck -> {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R viewModelScope.launch(Dispatchers.IO) {
val isPermissionGranted = val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
if (!legacyPermission) Environment.isExternalStorageManager() val isPermissionGranted =
else event.permissionState.status.isGranted if (!legacyPermission) Environment.isExternalStorageManager()
else event.permissionState.status.isGranted
if (isPermissionGranted) { if (isPermissionGranted) {
return return@launch
} }
_state.update {
it.copy( _state.update {
requestPermissionDialog = true, it.copy(
showErrorMessage = false requestPermissionDialog = true
) )
}
event.hideErrorMessage()
} }
} }
is BrowseEvent.OnSelectFile -> { is BrowseEvent.OnSelectFile -> {
val indexOfFile = _state.value.selectableFiles.indexOf(event.file) viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.selectableFiles.toMutableList() val indexOfFile = _state.value.selectableFiles.indexOf(event.file)
editedList[indexOfFile] = editedList[indexOfFile].copy(
second = !editedList[indexOfFile].second
)
_state.update { if (indexOfFile == -1) {
it.copy( return@launch
selectableFiles = editedList.toList(), }
selectedItemsCount = editedList.filter { file -> file.second }.size,
hasSelectedItems = editedList.any { file -> file.second } 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 -> { is BrowseEvent.OnSelectBook -> {
val indexOfFile = _state.value.selectedBooks.indexOf(event.book) viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.selectedBooks.toMutableList() val indexOfFile = _state.value.selectedBooks.indexOf(event.book)
editedList[indexOfFile] = NullableBook.NotNull(
editedList[indexOfFile].book!!.copy(
second = !editedList[indexOfFile].book!!.second
)
)
if (!editedList.any { it.book?.second == true }) { if (indexOfFile == -1) {
return return@launch
} }
_state.update { val editedList = _state.value.selectedBooks.toMutableList()
it.copy( editedList[indexOfFile] = NullableBook.NotNull(
selectedBooks = editedList 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) { viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
isLoading = true isLoading = true,
listState = LazyListState(0, 0)
) )
} }
getFilesFromDownloads() 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( private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else "" query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
) { ) {
getFilesFromDownloads.execute(query).collect { result -> getFilesFromDevice.execute(query).collect { result ->
when (result) { when (result) {
is Resource.Success -> { is Resource.Success -> {
_state.update { _state.update {
@ -371,13 +375,7 @@ class BrowseViewModel @Inject constructor(
} }
} }
is Resource.Loading -> { is Resource.Loading -> Unit
_state.update {
it.copy(
isLoading = result.isLoading
)
}
}
is Resource.Error -> 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource 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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.R
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CategoryTitle 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.CustomSnackbar
import ua.acclorite.book_story.presentation.components.MoreDropDown 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.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.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel 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.Transitions
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import java.util.UUID import java.util.UUID
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class, FlowPreview::class, ExperimentalFoundationApi::class ExperimentalMaterialApi::class, ExperimentalFoundationApi::class
) )
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
@ -89,7 +80,6 @@ fun HistoryScreen(
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val books = libraryViewModel.state.collectAsState().value.books.map { it.first } val books = libraryViewModel.state.collectAsState().value.books.map { it.first }
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
onRefresh = { onRefresh = {
@ -98,28 +88,6 @@ fun HistoryScreen(
) )
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val snackbarState = remember { SnackbarHostState() } 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) { if (state.showDeleteWholeHistoryDialog) {
HistoryDeleteWholeHistoryDialog(viewModel = viewModel, libraryViewModel = libraryViewModel) HistoryDeleteWholeHistoryDialog(viewModel = viewModel, libraryViewModel = libraryViewModel)
@ -134,7 +102,7 @@ fun HistoryScreen(
AnimatedTopAppBar( AnimatedTopAppBar(
scrolledContainerColor = MaterialTheme.elevation(), scrolledContainerColor = MaterialTheme.elevation(),
scrollBehavior = null, scrollBehavior = null,
isTopBarScrolled = (state.scrollIndex > 0 || state.scrollOffset > 0) && !state.isLoading, isTopBarScrolled = state.listState.canScrollBackward,
content1Visibility = !state.showSearch, content1Visibility = !state.showSearch,
content1NavigationIcon = {}, content1NavigationIcon = {},
@ -148,43 +116,39 @@ fun HistoryScreen(
) )
}, },
content1Actions = { content1Actions = {
IconButton( CustomIconButton(
enabled = !state.isRefreshing, icon = Icons.Default.Search,
onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) } contentDescription = stringResource(id = R.string.search_content_desc),
disableOnClick = false,
enabled = !state.showSearch
) { ) {
Icon( viewModel.onEvent(HistoryEvent.OnSearchShowHide)
imageVector = Icons.Default.Search,
contentDescription = "Search history",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
IconButton( CustomIconButton(
icon = Icons.Outlined.DeleteSweep,
contentDescription = stringResource(
id = R.string.delete_whole_history_content_desc
),
disableOnClick = false,
enabled = !state.isLoading enabled = !state.isLoading
&& !state.isRefreshing && !state.isRefreshing
&& state.history.isNotEmpty(), && state.history.isNotEmpty()
onClick = {
viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
}
) { ) {
Icon( viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete whole history",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
MoreDropDown(navigator = navigator) MoreDropDown(navigator = navigator)
}, },
content2Visibility = state.showSearch, content2Visibility = state.showSearch,
content2NavigationIcon = { content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }) { CustomIconButton(
Icon( icon = Icons.AutoMirrored.Default.ArrowBack,
imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = stringResource(
contentDescription = "Exit search mode", id = R.string.exit_search_content_desc
modifier = Modifier.size(24.dp), ),
tint = MaterialTheme.colorScheme.onSurface disableOnClick = true
) ) {
viewModel.onEvent(HistoryEvent.OnSearchShowHide)
} }
}, },
content2Title = { content2Title = {
@ -243,68 +207,72 @@ fun HistoryScreen(
.fillMaxSize() .fillMaxSize()
.padding(top = paddingValues.calculateTopPadding()) .padding(top = paddingValues.calculateTopPadding())
) { ) {
LazyColumn( DefaultTransition(visible = !state.isLoading) {
Modifier LazyColumn(
.fillMaxSize(), Modifier
state = listState, .fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp) state = state.listState,
) { contentPadding = PaddingValues(vertical = 12.dp)
if (!state.isLoading) { ) {
state.history.forEachIndexed { index, groupedHistory -> if (!state.isLoading) {
item(key = groupedHistory.title) { state.history.forEachIndexed { index, groupedHistory ->
if (index > 0) { 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)) Spacer(modifier = Modifier.height(8.dp))
} }
CategoryTitle( items(
modifier = Modifier.animateItemPlacement(), groupedHistory.history, key = { it.id ?: UUID.randomUUID() }
title = when (groupedHistory.title) { ) {
"today" -> stringResource(id = R.string.today) val book = remember {
"yesterday" -> stringResource(id = R.string.yesterday) books.find { book -> book.id == it.bookId }
else -> groupedHistory.title } ?: return@items
},
padding = 16.dp
)
Spacer(modifier = Modifier.height(8.dp))
}
items( HistoryItem(
groupedHistory.history, key = { it.id ?: UUID.randomUUID() } modifier = Modifier.animateItemPlacement(),
) { history = it,
val book = books.find { book -> book.id == it.bookId } ?: return@items book = book,
onBodyClick = {
HistoryItem( navigator.navigate(
modifier = Modifier.animateItemPlacement(), Screen.BOOK_INFO,
history = it, false,
book = book, Argument("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)
}
) )
) },
} 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) 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( PullRefreshIndicator(
state.isRefreshing, state.isRefreshing,

View file

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

View file

@ -20,6 +20,4 @@ sealed class HistoryEvent {
data class OnSearchQueryChange(val query: String) : HistoryEvent() data class OnSearchQueryChange(val query: String) : HistoryEvent()
data object OnSearchShowHide : HistoryEvent() data object OnSearchShowHide : HistoryEvent()
data class OnRequestFocus(val focusRequester: FocusRequester) : 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 package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.foundation.lazy.LazyListState
import ua.acclorite.book_story.domain.model.GroupedHistory import ua.acclorite.book_story.domain.model.GroupedHistory
data class HistoryState( data class HistoryState(
val history: List<GroupedHistory> = emptyList(), val history: List<GroupedHistory> = emptyList(),
val listState: LazyListState = LazyListState(0, 0),
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isLoading: Boolean = true, val isLoading: Boolean = true,

View file

@ -1,5 +1,6 @@
package ua.acclorite.book_story.presentation.screens.history.data package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -58,7 +59,8 @@ class HistoryViewModel @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
isRefreshing = true, isRefreshing = true,
showSearch = false showSearch = false,
listState = LazyListState(0, 0),
) )
} }
@ -76,12 +78,11 @@ class HistoryViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
isLoading = true isLoading = true,
listState = LazyListState(0, 0),
) )
} }
getHistoryFromDatabase() getHistoryFromDatabase()
onEvent(HistoryEvent.OnUpdateScrollIndex(0))
onEvent(HistoryEvent.OnUpdateScrollOffset(0))
} }
} }
@ -89,7 +90,8 @@ class HistoryViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
showDeleteWholeHistoryDialog = false showDeleteWholeHistoryDialog = false,
isLoading = true
) )
} }
@ -109,7 +111,7 @@ class HistoryViewModel @Inject constructor(
} }
is HistoryEvent.OnDeleteHistoryElement -> { is HistoryEvent.OnDeleteHistoryElement -> {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
deleteHistory.execute( deleteHistory.execute(
listOf(event.historyToDelete) 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 -> getHistory.execute().collect { result ->
when (result) { when (result) {
is Resource.Success -> { is Resource.Success -> {
_state.update {
it.copy(
history = emptyList()
)
}
val history = result.data?.sortedByDescending { it.time } ?: emptyList() val history = result.data?.sortedByDescending { it.time } ?: emptyList()
if (history.isEmpty()) { if (history.isEmpty()) {
_state.update { _state.update {
it.copy( it.copy(
history = emptyList(),
isLoading = false 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.pager.rememberPagerState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource 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.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar 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.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.data.Argument 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.components.dialog.LibraryMoveDialog
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel 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.Transitions
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import java.util.UUID import java.util.UUID
@ -178,26 +175,26 @@ fun LibraryScreen(
} }
}, },
content1Actions = { content1Actions = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnSearchShowHide) }) { CustomIconButton(
Icon( icon = Icons.Default.Search,
imageVector = Icons.Default.Search, contentDescription = stringResource(id = R.string.search_content_desc),
contentDescription = "Search books", disableOnClick = false,
modifier = Modifier.size(24.dp), enabled = !state.showSearch
tint = MaterialTheme.colorScheme.onSurfaceVariant ) {
) viewModel.onEvent(LibraryEvent.OnSearchShowHide)
} }
MoreDropDown(navigator = navigator) MoreDropDown(navigator = navigator)
}, },
content2Visibility = state.hasSelectedItems, content2Visibility = state.hasSelectedItems,
content2NavigationIcon = { content2NavigationIcon = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnClearSelectedBooks) }) { CustomIconButton(
Icon( icon = Icons.Default.Clear,
imageVector = Icons.Default.Clear, contentDescription =
contentDescription = "Clear selected items", stringResource(id = R.string.clear_selected_items_content_desc),
modifier = Modifier.size(24.dp), disableOnClick = true
tint = MaterialTheme.colorScheme.onSurface ) {
) viewModel.onEvent(LibraryEvent.OnClearSelectedBooks)
} }
}, },
content2Title = { content2Title = {
@ -213,37 +210,38 @@ fun LibraryScreen(
) )
}, },
content2Actions = { content2Actions = {
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog) }) { CustomIconButton(
Icon( icon = Icons.AutoMirrored.Outlined.DriveFileMove,
imageVector = Icons.AutoMirrored.Outlined.DriveFileMove, contentDescription = stringResource(
contentDescription = "Move books to another category", id = R.string.move_books_content_desc
modifier = Modifier.size(24.dp), ),
tint = MaterialTheme.colorScheme.onSurfaceVariant disableOnClick = true
) ) {
viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog)
} }
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnShowHideDeleteDialog) }) { CustomIconButton(
Icon( icon = Icons.Outlined.Delete,
imageVector = Icons.Outlined.Delete, contentDescription = stringResource(
contentDescription = "Delete books from database", id = R.string.delete_books_content_desc
modifier = Modifier.size(24.dp), ),
tint = MaterialTheme.colorScheme.onSurfaceVariant disableOnClick = true
) ) {
viewModel.onEvent(LibraryEvent.OnShowHideDeleteDialog)
} }
}, },
content3Visibility = state.showSearch && !state.hasSelectedItems, content3Visibility = state.showSearch && !state.hasSelectedItems,
content3NavigationIcon = { content3NavigationIcon = {
IconButton(onClick = { CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(
id = R.string.exit_search_content_desc
),
disableOnClick = true
) {
viewModel.onEvent( viewModel.onEvent(
LibraryEvent.OnSearchShowHide LibraryEvent.OnSearchShowHide
) )
}) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Exit search mode",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
} }
}, },
content3Title = { content3Title = {
@ -307,7 +305,7 @@ fun LibraryScreen(
HorizontalPager(state = pagerState, userScrollEnabled = !state.isRefreshing) { index -> HorizontalPager(state = pagerState, userScrollEnabled = !state.isRefreshing) { index ->
var categoryIsLoading by remember { mutableStateOf(true) } var categoryIsLoading by remember { mutableStateOf(true) }
val categorizedBooks = remember { mutableStateListOf<Pair<Book, Boolean>>() } val categorizedBooks = remember { mutableStateListOf<Pair<Book, Boolean>>() }
val category = Category.entries[index] val category = remember { Category.entries[index] }
LaunchedEffect(state.books) { LaunchedEffect(state.books) {
categorizedBooks.clear() categorizedBooks.clear()
@ -321,13 +319,13 @@ fun LibraryScreen(
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid( DefaultTransition(visible = !state.isLoading && !categoryIsLoading) {
columns = GridCells.Adaptive(120.dp), LazyVerticalGrid(
modifier = Modifier columns = GridCells.Adaptive(120.dp),
.fillMaxSize(), modifier = Modifier
contentPadding = PaddingValues(8.dp) .fillMaxSize(),
) { contentPadding = PaddingValues(8.dp)
if (!state.isLoading) { ) {
items( items(
categorizedBooks, categorizedBooks,
key = { it.first.id ?: UUID.randomUUID() } 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( AnimatedVisibility(
visible = !state.isLoading && !state.isRefreshing && categorizedBooks.isEmpty() visible = !state.isLoading && !state.isRefreshing && categorizedBooks.isEmpty()
&& !categoryIsLoading, && !categoryIsLoading,
@ -404,7 +392,7 @@ fun LibraryScreen(
val activity = LocalContext.current as ComponentActivity val activity = LocalContext.current as ComponentActivity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var shouldExit = false var shouldExit = remember { false }
BackHandler { BackHandler {
if (state.hasSelectedItems) { if (state.hasSelectedItems) {
viewModel.onEvent(LibraryEvent.OnClearSelectedBooks) viewModel.onEvent(LibraryEvent.OnClearSelectedBooks)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -74,15 +74,18 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
val backgroundColor = mainViewModel.backgroundColor.collectAsState().value!! val backgroundColor = mainViewModel.backgroundColor.collectAsState().value!!
val fontColor = mainViewModel.fontColor.collectAsState().value!! val fontColor = mainViewModel.fontColor.collectAsState().value!!
val scrimColor = val scrimColor = if (currentPage == 1) Color.Transparent
if (currentPage == 1) Color.Transparent else BottomSheetDefaults.ScrimColor else BottomSheetDefaults.ScrimColor
val animatedScrimColor by animateColorAsState( val animatedScrimColor by animateColorAsState(
targetValue = scrimColor, targetValue = scrimColor,
animationSpec = tween(300), animationSpec = tween(300),
label = "Scrim animation" 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( val animatedHeight by animateFloatAsState(
targetValue = height, targetValue = height,
animationSpec = tween(300), animationSpec = tween(300),

View file

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

View file

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

View file

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

View file

@ -119,7 +119,7 @@ private fun RevertibleSlider(
) { ) {
Icon( Icon(
imageVector = Icons.Default.History, imageVector = Icons.Default.History,
contentDescription = "Revert to initial", contentDescription = stringResource(id = R.string.revert_content_desc),
modifier = Modifier modifier = Modifier
.size(28.dp), .size(28.dp),
tint = if (initialValue == value.first) MaterialTheme.colorScheme.onSurfaceVariant 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.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,8 +32,10 @@ fun AppearanceSettingsThemeSwitcher(
val theme = mainViewModel.theme.collectAsState().value!! val theme = mainViewModel.theme.collectAsState().value!!
val darkTheme = mainViewModel.darkTheme.collectAsState().value!! val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
val themes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Constants.THEMES val themes = remember {
else Constants.THEMES.dropWhile { it.first == Theme.DYNAMIC } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Constants.THEMES
else Constants.THEMES.dropWhile { it.first == Theme.DYNAMIC }
}
Column( Column(
Modifier Modifier

View file

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

View file

@ -1,8 +1,9 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.general 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@ -18,8 +19,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.ChipItem import ua.acclorite.book_story.domain.model.ChipItem
import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.GoBackButton
@ -42,7 +43,6 @@ fun GeneralSettings(
listState.canScrollForward listState.canScrollForward
} }
) )
val context = LocalContext.current as ComponentActivity
val language = mainViewModel.language.collectAsState().value!! val language = mainViewModel.language.collectAsState().value!!
@ -84,16 +84,17 @@ fun GeneralSettings(
MaterialTheme.typography.labelLarge, MaterialTheme.typography.labelLarge,
it.first == language it.first == language
) )
} }.sortedBy { it.title }
) { ) {
mainViewModel.onEvent( mainViewModel.onEvent(
MainEvent.OnChangeLanguage( MainEvent.OnChangeLanguage(
it.id, it.id
context
) )
) )
} }
} }
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 --> <!-- Dialog Descriptions -->
<string name="storage_permission_description"> <string name="storage_permission_description">
Нам потрібен дозвіл на сховище, щоб просканувати вашу теку Завантажень. Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг.
Без цього дозволу ви не зможете додати книгу. Без цього дозволу ви не зможете додати книгу.
</string> </string>
<string name="add_books_description"> <string name="add_books_description">
@ -50,14 +50,11 @@
<!-- Errors --> <!-- Errors -->
<string name="error_permission"> <string name="error_permission">
Нам потрібен дозвіл на сховище, щоб просканувати вашу теку Завантажень та отримати книги Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг
</string> </string>
<string name="error_no_description"> <string name="error_no_description">
Немає опису. Немає опису.
</string> </string>
<string name="error_file_not_found">
Файл не знайдено. Будь ласка, перевірте чи файл цієї книги все ще існує.
</string>
<string name="error_something_went_wrong"> <string name="error_something_went_wrong">
Щось пішло не так. Будь ласка, перевірте чи файл цієї книги не пошкоджений. Щось пішло не так. Будь ласка, перевірте чи файл цієї книги не пошкоджений.
</string> </string>
@ -186,9 +183,9 @@
<!-- Toast messages --> <!-- Toast messages -->
<string name="press_again_toast">Натисніть ще раз, щоб вийти</string> <string name="press_again_toast">Натисніть ще раз, щоб вийти</string>
<string name="books_added">Всі книги були успішно додані.</string> <string name="books_added">Всі обрані книги були успішно додані.</string>
<string name="books_moved">Всі книги були успішно переміщені.</string> <string name="books_moved">Всі обрані книги були успішно переміщені.</string>
<string name="books_deleted">Всі книги були успішно видалені.</string> <string name="books_deleted">Всі обрані книги були успішно видалені.</string>
<string name="cover_image_changed">Обкладинка була успішно змінена.</string> <string name="cover_image_changed">Обкладинка була успішно змінена.</string>
<string name="cover_image_deleted">Обкладинка була успішно видалена.</string> <string name="cover_image_deleted">Обкладинка була успішно видалена.</string>
<string name="title_changed">Назва була успішно змінена.</string> <string name="title_changed">Назва була успішно змінена.</string>
@ -236,5 +233,23 @@
<string name="go_back_content_desc">Назад</string> <string name="go_back_content_desc">Назад</string>
<string name="cover_image_content_desc">Обкладинка</string> <string name="cover_image_content_desc">Обкладинка</string>
<string name="open_reader_settings_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> </resources>

View file

@ -1,6 +1,6 @@
<resources> <resources>
<!-- Basic Strings --> <!-- 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> <string name="app_name">Book\'s Story</string>
<!-- Screens --> <!-- Screens -->
@ -22,7 +22,7 @@
<!-- Dialog Descriptions --> <!-- Dialog Descriptions -->
<string name="storage_permission_description"> <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. Without this permission, you will not be able to add a book.
</string> </string>
<string name="add_books_description"> <string name="add_books_description">
@ -51,14 +51,11 @@
<!-- Errors --> <!-- Errors -->
<string name="error_permission"> <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>
<string name="error_no_description"> <string name="error_no_description">
No description. No description.
</string> </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"> <string name="error_something_went_wrong">
Something went wrong. Please check whether this book\'s file is not corrupted. Something went wrong. Please check whether this book\'s file is not corrupted.
</string> </string>
@ -186,9 +183,9 @@
<!-- Toast messages --> <!-- Toast messages -->
<string name="press_again_toast">Press back again to exit</string> <string name="press_again_toast">Press back again to exit</string>
<string name="books_added">All books were successfully added.</string> <string name="books_added">All selected books were successfully added.</string>
<string name="books_moved">All books were successfully moved.</string> <string name="books_moved">All selected books were successfully moved.</string>
<string name="books_deleted">All books were successfully deleted.</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_changed">Cover Image was successfully changed.</string>
<string name="cover_image_deleted">Cover Image was successfully deleted.</string> <string name="cover_image_deleted">Cover Image was successfully deleted.</string>
<string name="title_changed">Title was successfully changed.</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="go_back_content_desc">Go back</string>
<string name="cover_image_content_desc">Cover image</string> <string name="cover_image_content_desc">Cover image</string>
<string name="open_reader_settings_content_desc">Open reader settings</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> </resources>