feat(library): custom categories

* Custom categories
* New customization options
* Sorting

Resolves: #79
This commit is contained in:
Acclorite 2025-04-28 09:18:14 +03:00
parent e22e172ddb
commit c70338a14d
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
126 changed files with 4521 additions and 722 deletions

View file

@ -0,0 +1,24 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.converter
import androidx.room.TypeConverter
import kotlinx.serialization.json.Json
class CategoryConverter {
private val json = Json
@TypeConverter
fun fromCategoryList(value: List<Int>?): String {
return json.encodeToString(value)
}
@TypeConverter
fun toCategoryList(value: String): List<Int> {
return json.decodeFromString(value)
}
}

View file

@ -27,6 +27,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideCommonmarkParser(): Parser {

View file

@ -14,6 +14,10 @@ import ua.acclorite.book_story.data.local.data_store.DataStore
import ua.acclorite.book_story.data.local.data_store.DataStoreImpl
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.book.BookMapperImpl
import ua.acclorite.book_story.data.mapper.category.CategoryMapper
import ua.acclorite.book_story.data.mapper.category.CategoryMapperImpl
import ua.acclorite.book_story.data.mapper.category_sort.CategorySortMapper
import ua.acclorite.book_story.data.mapper.category_sort.CategorySortSortMapperImpl
import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapper
import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapperImpl
import ua.acclorite.book_story.data.mapper.history.HistoryMapper
@ -23,12 +27,14 @@ import ua.acclorite.book_story.data.parser.FileParserImpl
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.TextParserImpl
import ua.acclorite.book_story.data.repository.BookRepositoryImpl
import ua.acclorite.book_story.data.repository.CategoryRepositoryImpl
import ua.acclorite.book_story.data.repository.ColorPresetRepositoryImpl
import ua.acclorite.book_story.data.repository.DataStoreRepositoryImpl
import ua.acclorite.book_story.data.repository.FileSystemRepositoryImpl
import ua.acclorite.book_story.data.repository.HistoryRepositoryImpl
import ua.acclorite.book_story.data.repository.PermissionRepositoryImpl
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.repository.CategoryRepository
import ua.acclorite.book_story.domain.repository.ColorPresetRepository
import ua.acclorite.book_story.domain.repository.DataStoreRepository
import ua.acclorite.book_story.domain.repository.FileSystemRepository
@ -81,6 +87,12 @@ abstract class RepositoryModule {
permissionRepositoryImpl: PermissionRepositoryImpl
): PermissionRepository
@Binds
@Singleton
abstract fun bindCategoryRepository(
categoryRepositoryImpl: CategoryRepositoryImpl
): CategoryRepository
@Binds
@Singleton
abstract fun bindBookMapper(
@ -99,6 +111,18 @@ abstract class RepositoryModule {
colorPresetMapperImpl: ColorPresetMapperImpl
): ColorPresetMapper
@Binds
@Singleton
abstract fun bindCategoryMapper(
categoryMapperImpl: CategoryMapperImpl
): CategoryMapper
@Binds
@Singleton
abstract fun bindCategorySortMapper(
categorySortSortMapperImpl: CategorySortSortMapperImpl
): CategorySortMapper
@Binds
@Singleton
abstract fun bindFileParser(

View file

@ -6,11 +6,14 @@
package ua.acclorite.book_story.data.local.dto
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import ua.acclorite.book_story.domain.library.category.Category
import androidx.room.TypeConverters
import ua.acclorite.book_story.data.converter.CategoryConverter
@Entity
@TypeConverters(CategoryConverter::class)
data class BookEntity(
@PrimaryKey(true) val id: Int = 0,
val title: String,
@ -21,5 +24,5 @@ data class BookEntity(
val scrollOffset: Int,
val progress: Float,
val image: String? = null,
val category: Category
@ColumnInfo(defaultValue = "[]") val categories: List<Int>
)

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.local.dto
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class CategoryEntity(
@PrimaryKey(true)
val id: Int = 0,
val title: String,
val order: Int
)

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.local.dto
import androidx.room.Entity
import androidx.room.PrimaryKey
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
@Entity
data class CategorySortEntity(
@PrimaryKey(false)
val categoryId: Int,
val sortOrder: LibrarySortOrder,
val sortOrderDescending: Boolean
)

View file

@ -14,6 +14,8 @@ import androidx.room.Query
import androidx.room.Update
import androidx.room.Upsert
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.CategoryEntity
import ua.acclorite.book_story.data.local.dto.CategorySortEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity
@ -93,4 +95,47 @@ interface BookDao {
@Query("DELETE FROM colorpresetentity")
suspend fun deleteColorPresets()
/* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ CategoryEntity ----------------- */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCategory(
category: CategoryEntity
)
@Query("SELECT * FROM categoryentity")
suspend fun getCategories(): List<CategoryEntity>
@Query("SELECT COUNT(*) FROM categoryentity")
suspend fun getCategoriesCount(): Int
@Query("UPDATE categoryentity SET title=:title WHERE id=:id")
suspend fun updateCategoryTitle(
id: Int,
title: String
)
@Query("UPDATE categoryentity SET `order`=:order WHERE id=:id")
suspend fun updateCategoryOrder(
id: Int,
order: Int
)
@Delete
suspend fun deleteCategory(
category: CategoryEntity
)
/* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ CategorySortEntity ----------------- */
@Upsert
suspend fun updateCategorySort(categorySort: CategorySortEntity)
@Query("SELECT * FROM categorysortentity")
suspend fun getCategorySortEntities(): List<CategorySortEntity>
@Query("DELETE FROM categorysortentity WHERE categoryId=:categoryId")
suspend fun deleteCategorySortEntity(categoryId: Int)
/* - - - - - - - - - - - - - - - - - - - - - - */
}

View file

@ -16,6 +16,8 @@ import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.CategoryEntity
import ua.acclorite.book_story.data.local.dto.CategorySortEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity
import java.io.File
@ -25,8 +27,10 @@ import java.io.File
BookEntity::class,
HistoryEntity::class,
ColorPresetEntity::class,
CategoryEntity::class,
CategorySortEntity::class
],
version = 9,
version = 13,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3),
@ -36,6 +40,10 @@ import java.io.File
AutoMigration(6, 7),
AutoMigration(7, 8, spec = DatabaseHelper.MIGRATION_7_8::class),
AutoMigration(8, 9, spec = DatabaseHelper.MIGRATION_8_9::class),
AutoMigration(9, 10, spec = DatabaseHelper.MIGRATION_9_10::class),
AutoMigration(10, 11),
AutoMigration(11, 12),
AutoMigration(12, 13),
],
exportSchema = true
)
@ -113,4 +121,7 @@ object DatabaseHelper {
@DeleteTable("FavoriteDirectoryEntity")
class MIGRATION_8_9 : AutoMigrationSpec
@DeleteColumn("BookEntity", "category")
class MIGRATION_9_10 : AutoMigrationSpec
}

View file

@ -25,7 +25,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
author = book.author.getAsString(),
description = book.description,
image = book.coverImage?.toString(),
category = book.category
categories = book.categories
)
}
@ -42,8 +42,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
progress = bookEntity.progress,
filePath = bookEntity.filePath,
lastOpened = null,
category = bookEntity.category,
coverImage = bookEntity.image?.toUri()
coverImage = bookEntity.image?.toUri(),
categories = bookEntity.categories
)
}
}

View file

@ -0,0 +1,16 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.mapper.category
import ua.acclorite.book_story.data.local.dto.CategoryEntity
import ua.acclorite.book_story.domain.library.category.Category
interface CategoryMapper {
suspend fun toCategoryEntity(category: Category): CategoryEntity
suspend fun toCategory(categoryEntity: CategoryEntity): Category
}

View file

@ -0,0 +1,29 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.mapper.category
import ua.acclorite.book_story.data.local.dto.CategoryEntity
import ua.acclorite.book_story.domain.library.category.Category
import javax.inject.Inject
class CategoryMapperImpl @Inject constructor() : CategoryMapper {
override suspend fun toCategoryEntity(category: Category): CategoryEntity {
return CategoryEntity(
id = category.id,
title = category.title,
order = category.order
)
}
override suspend fun toCategory(categoryEntity: CategoryEntity): Category {
return Category(
id = categoryEntity.id,
title = categoryEntity.title,
order = categoryEntity.order
)
}
}

View file

@ -0,0 +1,16 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.mapper.category_sort
import ua.acclorite.book_story.data.local.dto.CategorySortEntity
import ua.acclorite.book_story.domain.library.category.CategorySort
interface CategorySortMapper {
suspend fun toCategorySortEntity(categorySort: CategorySort): CategorySortEntity
suspend fun toCategorySort(categorySortEntity: CategorySortEntity): CategorySort
}

View file

@ -0,0 +1,29 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.mapper.category_sort
import ua.acclorite.book_story.data.local.dto.CategorySortEntity
import ua.acclorite.book_story.domain.library.category.CategorySort
import javax.inject.Inject
class CategorySortSortMapperImpl @Inject constructor() : CategorySortMapper {
override suspend fun toCategorySortEntity(categorySort: CategorySort): CategorySortEntity {
return CategorySortEntity(
categoryId = categorySort.categoryId,
sortOrder = categorySort.sortOrder,
sortOrderDescending = categorySort.sortOrderDescending
)
}
override suspend fun toCategorySort(categorySortEntity: CategorySortEntity): CategorySort {
return CategorySort(
categoryId = categorySortEntity.categoryId,
sortOrder = categorySortEntity.sortOrder,
sortOrderDescending = categorySortEntity.sortOrderDescending
)
}
}

View file

@ -16,7 +16,6 @@ import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import java.io.File
import java.util.zip.ZipFile
@ -91,7 +90,7 @@ class EpubFileParser @Inject constructor() : FileParser {
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
categories = emptyList(),
coverImage = null
),
coverImage = extractCoverImageBitmap(rawFile, coverImage)

View file

@ -13,7 +13,6 @@ import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject
@ -56,7 +55,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
categories = emptyList(),
coverImage = null
),
coverImage = null

View file

@ -13,7 +13,6 @@ import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject
@ -42,7 +41,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
categories = emptyList(),
coverImage = null
),
coverImage = null

View file

@ -14,7 +14,6 @@ import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject
@ -47,7 +46,7 @@ class PdfFileParser @Inject constructor(
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
categories = emptyList(),
coverImage = null
),
coverImage = null

View file

@ -11,7 +11,6 @@ import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject
@ -32,7 +31,7 @@ class TxtFileParser @Inject constructor() : FileParser {
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
categories = emptyList(),
coverImage = null
),
coverImage = null

View file

@ -0,0 +1,71 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.repository
import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.category.CategoryMapper
import ua.acclorite.book_story.data.mapper.category_sort.CategorySortMapper
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CategoryRepositoryImpl @Inject constructor(
private val database: BookDao,
private val categoryMapper: CategoryMapper,
private val categorySortMapper: CategorySortMapper,
) : CategoryRepository {
override suspend fun insertCategory(category: Category) {
database.insertCategory(
categoryMapper.toCategoryEntity(
category.copy(
order = database.getCategoriesCount()
)
)
)
}
override suspend fun getCategories(): List<Category> {
return database.getCategories().map { categoryMapper.toCategory(it) }.sortedBy { it.order }
}
override suspend fun updateCategoryTitle(id: Int, title: String) {
database.updateCategoryTitle(
id = id,
title = title
)
updateCategoriesOrder(getCategories())
}
override suspend fun updateCategoriesOrder(categories: List<Category>) {
categories.mapIndexed { index, category ->
database.updateCategoryOrder(category.id, index)
}
}
override suspend fun deleteCategory(category: Category) {
database.deleteCategory(categoryMapper.toCategoryEntity(category))
database.deleteCategorySortEntity(category.id)
updateCategoriesOrder(getCategories())
}
override suspend fun updateCategorySortEntity(categorySort: CategorySort) {
database.updateCategorySort(
categorySortMapper.toCategorySortEntity(categorySort)
)
}
override suspend fun getCategorySortEntities(): List<CategorySort> {
return database.getCategorySortEntities().map {
categorySortMapper.toCategorySort(it)
}
}
}

View file

@ -10,7 +10,6 @@ import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
@Parcelize
@ -30,5 +29,5 @@ data class Book(
val progress: Float,
val lastOpened: Long?,
val category: Category
val categories: List<Int>
) : Parcelable

View file

@ -9,9 +9,8 @@ package ua.acclorite.book_story.domain.library.category
import androidx.compose.runtime.Immutable
@Immutable
enum class Category {
READING,
ALREADY_READ,
PLANNING,
DROPPED
}
data class Category(
val id: Int = 0,
val title: String,
val order: Int = 0
)

View file

@ -7,12 +7,11 @@
package ua.acclorite.book_story.domain.library.category
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
@Immutable
data class CategoryWithBooks(
val category: Category,
val title: UIText,
val books: List<SelectableBook>
data class CategorySort(
val categoryId: Int,
val sortOrder: LibrarySortOrder,
val sortOrderDescending: Boolean
)

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.library.display
import androidx.compose.runtime.Immutable
@Immutable
enum class LibraryLayout {
GRID, LIST
}
fun String.toLibraryLayout(): LibraryLayout {
return LibraryLayout.valueOf(this)
}

View file

@ -0,0 +1,21 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.library.display
import androidx.compose.runtime.Immutable
@Immutable
enum class LibrarySortOrder {
NAME,
LAST_READ,
PROGRESS,
AUTHOR
}
fun String.toLibrarySortOrder(): LibrarySortOrder {
return LibrarySortOrder.valueOf(this)
}

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.library.display
import androidx.compose.runtime.Immutable
@Immutable
enum class LibraryTitlePosition {
OFF, BELOW, INSIDE
}
fun String.toLibraryTitlePosition(): LibraryTitlePosition {
return LibraryTitlePosition.valueOf(this)
}

View file

@ -0,0 +1,38 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.repository
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
interface CategoryRepository {
suspend fun insertCategory(
category: Category
)
suspend fun getCategories(): List<Category>
suspend fun updateCategoryTitle(
id: Int,
title: String
)
suspend fun updateCategoriesOrder(
categories: List<Category>
)
suspend fun deleteCategory(
category: Category
)
suspend fun updateCategorySortEntity(
categorySort: CategorySort
)
suspend fun getCategorySortEntities(): List<CategorySort>
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class DeleteCategory @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(category: Category) {
repository.deleteCategory(category)
}
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class GetCategories @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(): List<Category> {
return repository.getCategories()
}
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class GetCategorySort @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(): List<CategorySort> {
return repository.getCategorySortEntities()
}
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class InsertCategory @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(category: Category) {
repository.insertCategory(category)
}
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class UpdateCategoryOrder @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(categories: List<Category>) {
repository.updateCategoriesOrder(categories)
}
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class UpdateCategorySort @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(categorySort: CategorySort) {
repository.updateCategorySortEntity(categorySort)
}
}

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.category
import ua.acclorite.book_story.domain.repository.CategoryRepository
import javax.inject.Inject
class UpdateCategoryTitle @Inject constructor(
private val repository: CategoryRepository
) {
suspend fun execute(id: Int, title: String) {
repository.updateCategoryTitle(id, title)
}
}

View file

@ -9,6 +9,7 @@ package ua.acclorite.book_story.presentation.book_info
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.util.BottomSheet
import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.book_info.BookInfoEvent
@ -16,6 +17,7 @@ import ua.acclorite.book_story.ui.book_info.BookInfoEvent
@Composable
fun BookInfoContent(
book: Book,
categories: List<Category>,
bottomSheet: BottomSheet?,
dialog: Dialog?,
listState: LazyListState,
@ -41,12 +43,13 @@ fun BookInfoContent(
dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit,
dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit,
navigateToReader: () -> Unit,
navigateToLibrary: () -> Unit,
navigateToLibrarySettings: () -> Unit,
navigateBack: () -> Unit
) {
BookInfoDialog(
dialog = dialog,
book = book,
categories = categories,
actionTitleDialog = actionTitleDialog,
actionAuthorDialog = actionAuthorDialog,
actionDescriptionDialog = actionDescriptionDialog,
@ -54,8 +57,8 @@ fun BookInfoContent(
actionDeleteDialog = actionDeleteDialog,
actionMoveDialog = actionMoveDialog,
dismissDialog = dismissDialog,
navigateToLibrary = navigateToLibrary,
navigateBack = navigateBack
navigateBack = navigateBack,
navigateToLibrarySettings = navigateToLibrarySettings
)
BookInfoBottomSheet(

View file

@ -8,6 +8,7 @@ package ua.acclorite.book_story.presentation.book_info
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.book_info.BookInfoEvent
import ua.acclorite.book_story.ui.book_info.BookInfoScreen
@ -16,6 +17,7 @@ import ua.acclorite.book_story.ui.book_info.BookInfoScreen
fun BookInfoDialog(
dialog: Dialog?,
book: Book,
categories: List<Category>,
actionTitleDialog: (BookInfoEvent.OnActionTitleDialog) -> Unit,
actionAuthorDialog: (BookInfoEvent.OnActionAuthorDialog) -> Unit,
actionDescriptionDialog: (BookInfoEvent.OnActionDescriptionDialog) -> Unit,
@ -24,7 +26,7 @@ fun BookInfoDialog(
actionMoveDialog: (BookInfoEvent.OnActionMoveDialog) -> Unit,
dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit,
navigateBack: () -> Unit,
navigateToLibrary: () -> Unit
navigateToLibrarySettings: () -> Unit
) {
when (dialog) {
BookInfoScreen.DELETE_DIALOG -> {
@ -38,9 +40,10 @@ fun BookInfoDialog(
BookInfoScreen.MOVE_DIALOG -> {
BookInfoMoveDialog(
book = book,
categories = categories,
actionMoveDialog = actionMoveDialog,
dismissDialog = dismissDialog,
navigateToLibrary = navigateToLibrary
navigateToLibrarySettings = navigateToLibrarySettings
)
}

View file

@ -6,69 +6,93 @@
package ua.acclorite.book_story.presentation.book_info
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.components.dialog.CheckboxDialogItem
import ua.acclorite.book_story.presentation.core.components.dialog.Dialog
import ua.acclorite.book_story.presentation.core.components.dialog.SelectableDialogItem
import ua.acclorite.book_story.ui.book_info.BookInfoEvent
@Composable
fun BookInfoMoveDialog(
book: Book,
categories: List<Category>,
actionMoveDialog: (BookInfoEvent.OnActionMoveDialog) -> Unit,
dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit,
navigateToLibrary: () -> Unit
navigateToLibrarySettings: () -> Unit
) {
val context = LocalContext.current
val categories = remember {
Category.entries.filter { book.category != it }
}
val selectedCategory = remember {
mutableStateOf(categories[0])
val selectedCategories = remember {
mutableStateListOf<Category>().apply {
clear()
categories.forEach { category ->
if (book.categories.any { it == category.id }) {
add(category)
}
}
}
}
Dialog(
title = stringResource(id = R.string.move_book),
icon = Icons.AutoMirrored.Outlined.DriveFileMove,
description = stringResource(
id = R.string.move_book_description
),
title = stringResource(id = R.string.move),
description = null,
actionEnabled = true,
onDismiss = { dismissDialog(BookInfoEvent.OnDismissDialog) },
onAction = {
actionMoveDialog(
BookInfoEvent.OnActionMoveDialog(
category = selectedCategory.value,
context = context,
navigateToLibrary = navigateToLibrary
selectedCategories = selectedCategories,
context = context
)
)
},
secondaryAction = stringResource(id = R.string.edit),
onSecondaryAction = {
navigateToLibrarySettings()
dismissDialog(BookInfoEvent.OnDismissDialog)
},
withContent = true,
items = {
items(categories, key = { it.name }) {
val category = when (it) {
Category.READING -> stringResource(id = R.string.reading_tab)
Category.ALREADY_READ -> stringResource(id = R.string.already_read_tab)
Category.PLANNING -> stringResource(id = R.string.planning_tab)
Category.DROPPED -> stringResource(id = R.string.dropped_tab)
items(categories) { category ->
val selected = remember(
category,
selectedCategories
) {
derivedStateOf {
selectedCategories.any { it == category }
}
}
SelectableDialogItem(
selected = it == selectedCategory.value,
title = category
CheckboxDialogItem(
selected = selected.value,
title = category.title
) {
selectedCategory.value = it
if (selected.value) selectedCategories.remove(category)
else selectedCategories.add(category)
}
}
if (categories.isEmpty()) {
item {
StyledText(
text = stringResource(id = R.string.categories_empty),
modifier = Modifier.padding(horizontal = 24.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}

View file

@ -57,7 +57,7 @@ fun BrowseFilterBottomSheet(
when (page) {
0 -> {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
BrowseFilterSubcategory(
BrowseSortSubcategory(
showTitle = false,
showDivider = false
)
@ -66,7 +66,7 @@ fun BrowseFilterBottomSheet(
1 -> {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
BrowseSortSubcategory(
BrowseFilterSubcategory(
showTitle = false,
showDivider = false
)

View file

@ -17,8 +17,8 @@ fun BrowseFilterBottomSheetTabRow(
scrollToPage: (Int) -> Unit
) {
val tabItems = listOf(
stringResource(id = R.string.filter_tab),
stringResource(id = R.string.sort_tab),
stringResource(id = R.string.filter_tab),
stringResource(id = R.string.display_tab)
)

View file

@ -0,0 +1,57 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.core.components.dialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun CheckboxDialogItem(
selected: Boolean,
title: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onClick()
}
.padding(start = 12.dp, end = 24.dp)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selected,
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.secondary,
checkmarkColor = MaterialTheme.colorScheme.onSecondary
),
onCheckedChange = { onClick() }
)
Spacer(modifier = Modifier.width(12.dp))
StyledText(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
}
}

View file

@ -10,7 +10,6 @@ package ua.acclorite.book_story.presentation.core.constants
import androidx.compose.ui.graphics.Color
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.reader.ColorPreset
import ua.acclorite.book_story.domain.ui.UIText
@ -29,7 +28,7 @@ fun provideEmptyBook() = Book(
scrollOffset = 0,
progress = 0f,
lastOpened = null,
category = Category.READING
categories = emptyList()
)
// Default Color Preset

View file

@ -67,6 +67,20 @@ object DataStoreConstants {
val PROGRESS_BAR_FONT_SIZE = intPreferencesKey("progress_bar_font_size")
val PROGRESS_COUNT = stringPreferencesKey("progress_count")
// Library settings
val LIBRARY_LAYOUT = stringPreferencesKey("library_layout")
val LIBRARY_AUTO_GRID_SIZE = booleanPreferencesKey("library_auto_grid_size")
val LIBRARY_GRID_SIZE = intPreferencesKey("library_grid_size")
val LIBRARY_READ_BUTTON = booleanPreferencesKey("library_read_button")
val LIBRARY_SHOW_PROGRESS = booleanPreferencesKey("library_show_progress")
val LIBRARY_TITLE_POSITION = stringPreferencesKey("library_title_position")
val LIBRARY_SHOW_BOOK_COUNT = booleanPreferencesKey("library_show_book_count")
val LIBRARY_SHOW_CATEGORY_TABS = booleanPreferencesKey("library_show_category_tabs")
val LIBRARY_ALWAYS_SHOW_DEFAULT_TAB = booleanPreferencesKey("library_always_show_default_tab")
val LIBRARY_SORT_ORDER = stringPreferencesKey("library_sort_order")
val LIBRARY_SORT_ORDER_DESCENDING = booleanPreferencesKey("library_sort_order_descending")
val LIBRARY_PER_CATEGORY_SORT = booleanPreferencesKey("library_per_category_sort")
// Browse settings
val BROWSE_LAYOUT = stringPreferencesKey("browse_layout")
val BROWSE_AUTO_GRID_SIZE = booleanPreferencesKey("browse_auto_grid_size")

View file

@ -65,4 +65,15 @@ fun Char.isVisibleCharacter(): Boolean {
else -> false
}
}
fun <T> compareByWithOrder(
descending: Boolean,
selector: (T) -> Comparable<*>?
): Comparator<T> {
return if (descending) {
compareByDescending(selector)
} else {
compareBy(selector)
}
}

View file

@ -0,0 +1,52 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.util.BottomSheet
import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.library.LibraryScreen
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.settings.SettingsEvent
@Composable
fun LibraryBottomSheet(
bottomSheet: BottomSheet?,
categories: List<Category>,
showDefaultCategory: Boolean,
pagerState: PagerState,
categoriesSort: List<CategorySort>,
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
perCategorySort: Boolean,
changeLibrarySortOrder: (MainEvent.OnChangeLibrarySortOrder) -> Unit,
changeLibrarySortOrderDescending: (MainEvent.OnChangeLibrarySortOrderDescending) -> Unit,
updateCategorySort: (SettingsEvent.OnUpdateCategorySort) -> Unit,
dismissBottomSheet: (LibraryEvent.OnDismissBottomSheet) -> Unit
) {
when (bottomSheet) {
LibraryScreen.FILTER_BOTTOM_SHEET -> {
LibraryFilterBottomSheet(
categories = categories,
showDefaultCategory = showDefaultCategory,
categoriesPagerState = pagerState,
sortOrder = sortOrder,
sortOrderDescending = sortOrderDescending,
categoriesSort = categoriesSort,
perCategorySort = perCategorySort,
changeLibrarySortOrder = changeLibrarySortOrder,
changeLibrarySortOrderDescending = changeLibrarySortOrderDescending,
updateCategorySort = updateCategorySort,
dismissBottomSheet = dismissBottomSheet
)
}
}
}

View file

@ -12,9 +12,16 @@ import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.domain.util.BottomSheet
import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.settings.SettingsEvent
@OptIn(ExperimentalMaterialApi::class)
@Composable
@ -22,6 +29,11 @@ fun LibraryContent(
books: List<SelectableBook>,
selectedItemsCount: Int,
hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
showCategoryTabs: Boolean,
showBookCount: Boolean,
showSearch: Boolean,
searchQuery: String,
bookCount: Int,
@ -30,9 +42,21 @@ fun LibraryContent(
isLoading: Boolean,
isRefreshing: Boolean,
doublePressExit: Boolean,
categories: List<CategoryWithBooks>,
categories: List<Category>,
showDefaultCategory: Boolean,
perCategorySort: Boolean,
categoriesSort: List<CategorySort>,
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
layout: LibraryLayout,
gridSize: Int,
autoGridSize: Boolean,
refreshState: PullRefreshState,
dialog: Dialog?,
bottomSheet: BottomSheet?,
updateCategorySort: (SettingsEvent.OnUpdateCategorySort) -> Unit,
changeLibrarySortOrder: (MainEvent.OnChangeLibrarySortOrder) -> Unit,
changeLibrarySortOrderDescending: (MainEvent.OnChangeLibrarySortOrderDescending) -> Unit,
selectBook: (LibraryEvent.OnSelectBook) -> Unit,
searchVisibility: (LibraryEvent.OnSearchVisibility) -> Unit,
requestFocus: (LibraryEvent.OnRequestFocus) -> Unit,
@ -44,9 +68,12 @@ fun LibraryContent(
actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit,
actionDeleteDialog: (LibraryEvent.OnActionDeleteDialog) -> Unit,
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit,
showFilterBottomSheet: (LibraryEvent.OnShowFilterBottomSheet) -> Unit,
dismissBottomSheet: (LibraryEvent.OnDismissBottomSheet) -> Unit,
navigateToBrowse: () -> Unit,
navigateToBookInfo: (id: Int) -> Unit,
navigateToReader: (id: Int) -> Unit
navigateToReader: (id: Int) -> Unit,
navigateToLibrarySettings: () -> Unit
) {
LibraryDialog(
dialog = dialog,
@ -55,12 +82,34 @@ fun LibraryContent(
selectedItemsCount = selectedItemsCount,
actionMoveDialog = actionMoveDialog,
actionDeleteDialog = actionDeleteDialog,
dismissDialog = dismissDialog
dismissDialog = dismissDialog,
navigateToLibrarySettings = navigateToLibrarySettings
)
LibraryBottomSheet(
bottomSheet = bottomSheet,
categories = categories,
showDefaultCategory = showDefaultCategory,
pagerState = pagerState,
categoriesSort = categoriesSort,
sortOrder = sortOrder,
sortOrderDescending = sortOrderDescending,
perCategorySort = perCategorySort,
changeLibrarySortOrder = changeLibrarySortOrder,
changeLibrarySortOrderDescending = changeLibrarySortOrderDescending,
updateCategorySort = updateCategorySort,
dismissBottomSheet = dismissBottomSheet
)
LibraryScaffold(
books = books,
selectedItemsCount = selectedItemsCount,
hasSelectedItems = hasSelectedItems,
titlePosition = titlePosition,
readButton = readButton,
showProgress = showProgress,
showBookCount = showBookCount,
showCategoryTabs = showCategoryTabs,
showSearch = showSearch,
searchQuery = searchQuery,
bookCount = bookCount,
@ -68,7 +117,15 @@ fun LibraryContent(
pagerState = pagerState,
isLoading = isLoading,
isRefreshing = isRefreshing,
layout = layout,
gridSize = gridSize,
autoGridSize = autoGridSize,
categories = categories,
showDefaultCategory = showDefaultCategory,
perCategorySort = perCategorySort,
categoriesSort = categoriesSort,
sortOrder = sortOrder,
sortOrderDescending = sortOrderDescending,
searchVisibility = searchVisibility,
requestFocus = requestFocus,
searchQueryChange = searchQueryChange,
@ -77,6 +134,7 @@ fun LibraryContent(
clearSelectedBooks = clearSelectedBooks,
showMoveDialog = showMoveDialog,
showDeleteDialog = showDeleteDialog,
showFilterBottomSheet = showFilterBottomSheet,
refreshState = refreshState,
navigateToBrowse = navigateToBrowse,
navigateToBookInfo = navigateToBookInfo,

View file

@ -8,7 +8,7 @@ package ua.acclorite.book_story.presentation.library
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.library.LibraryScreen
@ -17,20 +17,21 @@ import ua.acclorite.book_story.ui.library.LibraryScreen
fun LibraryDialog(
dialog: Dialog?,
books: List<SelectableBook>,
categories: List<CategoryWithBooks>,
categories: List<Category>,
selectedItemsCount: Int,
actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit,
actionDeleteDialog: (LibraryEvent.OnActionDeleteDialog) -> Unit,
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit,
navigateToLibrarySettings: () -> Unit
) {
when (dialog) {
LibraryScreen.MOVE_DIALOG -> {
LibraryMoveDialog(
books = books,
categories = categories,
selectedItemsCount = selectedItemsCount,
actionMoveDialog = actionMoveDialog,
dismissDialog = dismissDialog
dismissDialog = dismissDialog,
navigateToLibrarySettings = navigateToLibrarySettings
)
}

View file

@ -0,0 +1,156 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
import ua.acclorite.book_story.presentation.core.components.modal_bottom_sheet.ModalBottomSheet
import ua.acclorite.book_story.presentation.settings.library.display.LibraryDisplaySubcategory
import ua.acclorite.book_story.presentation.settings.library.sort.components.LibrarySortOption
import ua.acclorite.book_story.presentation.settings.library.tabs.LibraryTabsSubcategory
import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.settings.SettingsEvent
private var initialPage = 0
@Composable
fun LibraryFilterBottomSheet(
categories: List<Category>,
showDefaultCategory: Boolean,
categoriesPagerState: PagerState,
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
categoriesSort: List<CategorySort>,
perCategorySort: Boolean,
changeLibrarySortOrder: (MainEvent.OnChangeLibrarySortOrder) -> Unit,
changeLibrarySortOrderDescending: (MainEvent.OnChangeLibrarySortOrderDescending) -> Unit,
updateCategorySort: (SettingsEvent.OnUpdateCategorySort) -> Unit,
dismissBottomSheet: (LibraryEvent.OnDismissBottomSheet) -> Unit
) {
val currentCategory = remember(
categories,
showDefaultCategory,
categoriesPagerState.currentPage
) {
derivedStateOf {
categories.toMutableList().apply {
if (showDefaultCategory) {
add(0, Category(id = -1, title = ""))
}
}[categoriesPagerState.currentPage]
}
}
val currentCategorySort = remember(currentCategory, categoriesSort) {
derivedStateOf {
categoriesSort.firstOrNull { it.categoryId == currentCategory.value.id }
?: CategorySort(
categoryId = currentCategory.value.id,
sortOrder = LibrarySortOrder.LAST_READ,
sortOrderDescending = true
)
}
}
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage) { 2 }
DisposableEffect(Unit) { onDispose { initialPage = pagerState.currentPage } }
ModalBottomSheet(
hasFixedHeight = true,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
dragHandle = {},
onDismissRequest = {
dismissBottomSheet(LibraryEvent.OnDismissBottomSheet)
},
sheetGesturesEnabled = false
) {
LibraryFilterBottomSheetTabRow(
currentPage = pagerState.currentPage,
scrollToPage = {
scope.launch {
pagerState.animateScrollToPage(it)
}
}
)
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
when (page) {
0 -> {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 18.dp)
) {
LibrarySortOption(
sortOrder = if (perCategorySort) {
currentCategorySort.value.sortOrder
} else sortOrder,
sortOrderDescending = if (perCategorySort) {
currentCategorySort.value.sortOrderDescending
} else sortOrderDescending,
onChange = { sortOrder, sortOrderDescending ->
if (perCategorySort) {
updateCategorySort(
SettingsEvent.OnUpdateCategorySort(
categoryId = currentCategorySort.value.categoryId,
sortOrder = sortOrder,
sortOrderDescending = sortOrderDescending
)
)
} else {
changeLibrarySortOrder(
MainEvent.OnChangeLibrarySortOrder(
sortOrder.name
)
)
changeLibrarySortOrderDescending(
MainEvent.OnChangeLibrarySortOrderDescending(
sortOrderDescending
)
)
}
}
)
}
}
1 -> {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
LibraryDisplaySubcategory(
showTitle = false
)
LibraryTabsSubcategory(
titleColor = { MaterialTheme.colorScheme.onSurface },
showDivider = false
)
}
}
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.modal_bottom_sheet.ModalBottomSheetTabRow
@Composable
fun LibraryFilterBottomSheetTabRow(
currentPage: Int,
scrollToPage: (Int) -> Unit
) {
val tabItems = listOf(
stringResource(id = R.string.sort_tab),
stringResource(id = R.string.display_tab),
)
ModalBottomSheetTabRow(
selectedTabIndex = currentPage,
tabs = tabItems
) { index ->
scrollToPage(index)
}
}

View file

@ -0,0 +1,202 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.presentation.core.components.common.AsyncCoverImage
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.util.calculateProgress
@Composable
fun LibraryGridItem(
book: SelectableBook,
hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
selectBook: (select: Boolean?) -> Unit,
navigateToBookInfo: () -> Unit,
navigateToReader: () -> Unit
) {
val backgroundColor = if (book.selected) MaterialTheme.colorScheme.secondary
else Color.Transparent
val belowFontColor = if (book.selected) MaterialTheme.colorScheme.onSecondary
else MaterialTheme.colorScheme.onSurface
val scrimColor = if (titlePosition == LibraryTitlePosition.INSIDE) {
MaterialTheme.colorScheme.scrim.copy(0.3f)
} else Color.Transparent
val insideFontColor = Color.White.copy(0.85f)
val progress = rememberSaveable(book.data.progress) {
"${book.data.progress.calculateProgress(1)}%"
}
Column(
Modifier
.padding(3.dp)
.clip(MaterialTheme.shapes.large)
.background(backgroundColor)
.padding(3.dp)
) {
Box(
modifier = Modifier
.aspectRatio(1f / 1.5f)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.combinedClickable(
onClick = {
if (hasSelectedItems) selectBook(null)
else navigateToBookInfo()
},
onLongClick = {
if (!hasSelectedItems) selectBook(true)
}
)
) {
if (book.data.coverImage != null) {
AsyncCoverImage(
uri = book.data.coverImage,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium)
)
} else {
Icon(
imageVector = Icons.Default.Image,
contentDescription = stringResource(
id = R.string.cover_image_not_found_content_desc
),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
.aspectRatio(1f),
tint = MaterialTheme.colorScheme.surfaceContainerHigh
)
}
if (showProgress) {
StyledText(
text = progress,
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.tertiary)
.padding(horizontal = 6.dp, vertical = 3.dp),
style = MaterialTheme.typography.bodySmall.copy(
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onTertiary,
)
)
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.drawWithContent {
drawRect(
brush = Brush.verticalGradient(
0f to Color.Transparent,
1f to scrimColor
)
)
drawContent()
}
.padding(6.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.End
) {
if (titlePosition == LibraryTitlePosition.INSIDE) {
StyledText(
text = book.data.title,
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp),
style = MaterialTheme.typography.bodySmall.copy(
color = insideFontColor
),
maxLines = 2
)
Spacer(modifier = Modifier.width(6.dp))
}
if (readButton) {
FilledIconButton(
onClick = {
if (readButton) navigateToReader()
},
modifier = Modifier.size(32.dp),
shape = MaterialTheme.shapes.medium,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(id = R.string.continue_reading_content_desc),
Modifier.size(20.dp)
)
}
}
}
}
if (titlePosition == LibraryTitlePosition.BELOW) {
Spacer(modifier = Modifier.height(6.dp))
StyledText(
text = book.data.title,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = belowFontColor
),
minLines = 2,
maxLines = 2
)
Spacer(modifier = Modifier.height(2.dp))
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.presentation.core.components.common.LazyVerticalGridWithScrollbar
import ua.acclorite.book_story.presentation.core.constants.providePrimaryScrollbar
@Composable
fun LibraryGridLayout(
books: List<SelectableBook>,
gridSize: Int,
autoGridSize: Boolean,
itemContent: @Composable (book: SelectableBook) -> Unit
) {
LazyVerticalGridWithScrollbar(
columns = if (autoGridSize) GridCells.Adaptive(120.dp)
else GridCells.Fixed(gridSize.coerceAtLeast(1)),
modifier = Modifier.fillMaxSize(),
scrollbarSettings = providePrimaryScrollbar(false),
contentPadding = PaddingValues(8.dp)
) {
items(
books,
key = { it.data.id }
) { book ->
Box(modifier = Modifier.animateItem()) {
itemContent(book)
}
}
}
}

View file

@ -6,151 +6,47 @@
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.presentation.core.components.common.AsyncCoverImage
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.util.calculateProgress
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
@Composable
fun LazyGridItemScope.LibraryItem(
fun LibraryItem(
book: SelectableBook,
layout: LibraryLayout,
hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
selectBook: (select: Boolean?) -> Unit,
navigateToBookInfo: () -> Unit,
navigateToReader: () -> Unit
) {
val backgroundColor = if (book.selected) MaterialTheme.colorScheme.secondary
else Color.Transparent
val fontColor = if (book.selected) MaterialTheme.colorScheme.onSecondary
else MaterialTheme.colorScheme.onSurface
val progress = rememberSaveable(book.data.progress) {
"${book.data.progress.calculateProgress(1)}%"
}
Column(
Modifier
.animateItem()
.padding(3.dp)
.background(
backgroundColor,
MaterialTheme.shapes.large
when (layout) {
LibraryLayout.LIST -> {
LibraryListItem(
book = book,
hasSelectedItems = hasSelectedItems,
readButton = readButton,
showProgress = showProgress,
selectBook = selectBook,
navigateToBookInfo = navigateToBookInfo,
navigateToReader = navigateToReader
)
}
LibraryLayout.GRID -> {
LibraryGridItem(
book = book,
hasSelectedItems = hasSelectedItems,
titlePosition = titlePosition,
readButton = readButton,
showProgress = showProgress,
selectBook = selectBook,
navigateToBookInfo = navigateToBookInfo,
navigateToReader = navigateToReader
)
.padding(3.dp)
.padding(bottom = 2.dp)
) {
Box(
modifier = Modifier
.aspectRatio(1f / 1.5f)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.medium
)
.combinedClickable(
onClick = {
if (hasSelectedItems) selectBook(null)
else navigateToBookInfo()
},
onLongClick = {
if (!hasSelectedItems) selectBook(true)
}
)
) {
if (book.data.coverImage != null) {
AsyncCoverImage(
uri = book.data.coverImage,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium)
)
} else {
Icon(
imageVector = Icons.Default.Image,
contentDescription = stringResource(
id = R.string.cover_image_not_found_content_desc
),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
.aspectRatio(1f),
tint = MaterialTheme.colorScheme.surfaceContainerHigh
)
}
StyledText(
text = progress,
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.secondary, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall.copy(
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSecondary,
)
)
FilledIconButton(
onClick = { navigateToReader() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.size(32.dp),
shape = MaterialTheme.shapes.medium,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(id = R.string.continue_reading_content_desc),
Modifier.size(20.dp)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
StyledText(
text = book.data.title,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = fontColor
),
minLines = 2,
maxLines = 2
)
}
}

View file

@ -6,26 +6,33 @@
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.core.components.common.LazyVerticalGridWithScrollbar
import ua.acclorite.book_story.presentation.core.constants.providePrimaryScrollbar
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.display.LibraryLayout
@Composable
fun LibraryLayout(
items: LazyGridScope.() -> Unit
books: List<SelectableBook>,
layout: LibraryLayout,
gridSize: Int,
autoGridSize: Boolean,
itemContent: @Composable (book: SelectableBook) -> Unit
) {
LazyVerticalGridWithScrollbar(
columns = GridCells.Adaptive(120.dp),
modifier = Modifier.fillMaxSize(),
scrollbarSettings = providePrimaryScrollbar(false),
contentPadding = PaddingValues(8.dp)
) {
items()
when (layout) {
LibraryLayout.LIST -> {
LibraryListLayout(
books = books,
itemContent = itemContent
)
}
LibraryLayout.GRID -> {
LibraryGridLayout(
books = books,
gridSize = gridSize,
autoGridSize = autoGridSize,
itemContent = itemContent
)
}
}
}

View file

@ -0,0 +1,152 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.presentation.core.components.common.AsyncCoverImage
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.util.calculateProgress
@Composable
fun LibraryListItem(
book: SelectableBook,
hasSelectedItems: Boolean,
readButton: Boolean,
showProgress: Boolean,
selectBook: (select: Boolean?) -> Unit,
navigateToBookInfo: () -> Unit,
navigateToReader: () -> Unit
) {
val backgroundColor = if (book.selected) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
val fontColor = if (book.selected) MaterialTheme.colorScheme.onSecondaryContainer
else MaterialTheme.colorScheme.onSurface
val progress = rememberSaveable(book.data.progress) {
"${book.data.progress.calculateProgress(1)}%"
}
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
.clip(MaterialTheme.shapes.medium)
.background(backgroundColor)
.combinedClickable(
onClick = {
if (hasSelectedItems) selectBook(null)
else navigateToBookInfo()
},
onLongClick = {
if (!hasSelectedItems) selectBook(true)
}
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(50.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
if (book.data.coverImage != null) {
AsyncCoverImage(
uri = book.data.coverImage,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.small)
)
} else {
Icon(
imageVector = Icons.Default.Image,
contentDescription = stringResource(id = R.string.cover_image_not_found_content_desc),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
.aspectRatio(1f),
tint = MaterialTheme.colorScheme.surfaceContainerHigh
)
}
}
Spacer(modifier = Modifier.width(16.dp))
StyledText(
text = book.data.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium.copy(
color = fontColor
),
maxLines = 2
)
Spacer(modifier = Modifier.width(8.dp))
if (showProgress) {
Spacer(modifier = Modifier.width(8.dp))
StyledText(
text = progress,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.tertiary)
.padding(horizontal = 6.dp, vertical = 3.dp),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onTertiary
)
)
}
if (readButton) {
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = { if (readButton) navigateToReader() },
modifier = Modifier.size(32.dp),
shape = MaterialTheme.shapes.medium,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(id = R.string.continue_reading_content_desc),
modifier = Modifier.size(20.dp)
)
}
}
}
}

View file

@ -0,0 +1,39 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
import ua.acclorite.book_story.presentation.core.constants.providePrimaryScrollbar
@Composable
fun LibraryListLayout(
books: List<SelectableBook>,
itemContent: @Composable (book: SelectableBook) -> Unit
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
scrollbarSettings = providePrimaryScrollbar(false),
contentPadding = PaddingValues(8.dp)
) {
items(
books,
key = { it.data.id }
) { book ->
Box(modifier = Modifier.animateItem()) {
itemContent(book)
}
}
}
}

View file

@ -6,57 +6,54 @@
package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoveUp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.components.dialog.CheckboxDialogItem
import ua.acclorite.book_story.presentation.core.components.dialog.Dialog
import ua.acclorite.book_story.presentation.core.components.dialog.SelectableDialogItem
import ua.acclorite.book_story.ui.library.LibraryEvent
@Composable
fun LibraryMoveDialog(
books: List<SelectableBook>,
categories: List<CategoryWithBooks>,
selectedItemsCount: Int,
categories: List<Category>,
actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit,
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit,
navigateToLibrarySettings: () -> Unit
) {
val context = LocalContext.current
val selectedBooks = remember {
derivedStateOf {
books.filter { it.selected }
}
}
val moveCategories = remember {
derivedStateOf {
categories.mapNotNull { category ->
if (!selectedBooks.value.all { it.data.category == category.category }) {
return@mapNotNull category
val selectedCategories = remember {
mutableStateListOf<Category>().apply {
clear()
categories.forEach { category ->
if (selectedBooks.value.all { it.data.categories.any { it == category.id } }) {
add(category)
}
return@mapNotNull null
}
}
}
val selectedCategory = remember {
mutableStateOf(moveCategories.value[0])
}
Dialog(
title = stringResource(id = R.string.move_books),
icon = Icons.Outlined.MoveUp,
description = stringResource(
id = R.string.move_books_description,
selectedItemsCount
),
title = stringResource(id = R.string.move),
description = null,
actionEnabled = true,
onDismiss = {
dismissDialog(LibraryEvent.OnDismissDialog)
@ -64,20 +61,46 @@ fun LibraryMoveDialog(
onAction = {
actionMoveDialog(
LibraryEvent.OnActionMoveDialog(
selectedCategory = selectedCategory.value.category,
categories = categories,
selectedCategories = selectedCategories,
context = context
)
)
},
secondaryAction = stringResource(id = R.string.edit),
onSecondaryAction = {
navigateToLibrarySettings()
dismissDialog(LibraryEvent.OnDismissDialog)
},
withContent = true,
items = {
items(moveCategories.value) { category ->
SelectableDialogItem(
selected = category == selectedCategory.value,
title = category.title.asString()
items(categories) { category ->
val selected = remember(
category,
selectedCategories
) {
selectedCategory.value = category
derivedStateOf {
selectedCategories.any { it == category }
}
}
CheckboxDialogItem(
selected = selected.value,
title = category.title
) {
if (selected.value) selectedCategories.remove(category)
else selectedCategories.add(category)
}
}
if (categories.isEmpty()) {
item {
StyledText(
text = stringResource(id = R.string.categories_empty),
modifier = Modifier.padding(horizontal = 24.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}

View file

@ -8,22 +8,39 @@ package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.presentation.core.util.compareByWithOrder
import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.theme.DefaultTransition
@Composable
fun LibraryPager(
books: List<SelectableBook>,
pagerState: PagerState,
categories: List<CategoryWithBooks>,
categories: List<Category>,
showDefaultCategory: Boolean,
perCategorySort: Boolean,
categoriesSort: List<CategorySort>,
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
layout: LibraryLayout,
gridSize: Int,
autoGridSize: Boolean,
hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
isLoading: Boolean,
isRefreshing: Boolean,
selectBook: (LibraryEvent.OnSelectBook) -> Unit,
@ -31,42 +48,140 @@ fun LibraryPager(
navigateToBookInfo: (id: Int) -> Unit,
navigateToReader: (id: Int) -> Unit,
) {
val categorizedBooks = remember(
books,
categories,
categoriesSort,
perCategorySort,
sortOrder,
sortOrderDescending,
showDefaultCategory
) {
fun List<SelectableBook>.sortBooks(
librarySortOrder: LibrarySortOrder,
librarySortOrderDescending: Boolean
): List<SelectableBook> {
return sortedWith(
compareByWithOrder(librarySortOrderDescending) { book ->
when (librarySortOrder) {
LibrarySortOrder.NAME -> book.data.title.trim()
LibrarySortOrder.LAST_READ -> book.data.lastOpened
LibrarySortOrder.PROGRESS -> book.data.progress
LibrarySortOrder.AUTHOR -> book.data.author.getAsString()
}
}
)
}
derivedStateOf {
val categorizedBooks = mutableListOf<List<SelectableBook>>()
if (showDefaultCategory) {
val categoryIds = categories.map { it.id }.toSet()
categorizedBooks.add(
books.filter { book ->
book.data.categories.none { category -> category in categoryIds }
}.let {
if (perCategorySort) {
val categorySort = categoriesSort.firstOrNull {
it.categoryId == -1
} ?: CategorySort(
categoryId = -1,
sortOrder = LibrarySortOrder.LAST_READ,
sortOrderDescending = true
)
return@let it.sortBooks(
categorySort.sortOrder,
categorySort.sortOrderDescending
)
}
return@let it
}
)
}
categories.sortedBy { it.order }.forEach { category ->
categorizedBooks.add(
books
.filter { it.data.categories.any { category.id == it } }
.let {
if (perCategorySort) {
val categorySort = categoriesSort.firstOrNull {
it.categoryId == category.id
} ?: CategorySort(
categoryId = category.id,
sortOrder = LibrarySortOrder.LAST_READ,
sortOrderDescending = true
)
return@let it.sortBooks(
categorySort.sortOrder,
categorySort.sortOrderDescending
)
}
return@let it
}
)
}
return@derivedStateOf categorizedBooks.let {
if (!perCategorySort) {
return@let it.map {
it.sortBooks(
sortOrder,
sortOrderDescending
)
}
}
return@let it
}
}
}
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { index ->
val category = remember(categories, index) {
val category = remember(categorizedBooks, index) {
derivedStateOf {
categories[index]
categorizedBooks.value[index]
}
}
Box(modifier = Modifier.fillMaxSize()) {
DefaultTransition(visible = !isLoading) {
LibraryLayout {
items(
category.value.books,
key = { it.data.id }
) { book ->
LibraryItem(
book = book,
hasSelectedItems = hasSelectedItems,
selectBook = { select ->
selectBook(
LibraryEvent.OnSelectBook(
id = book.data.id,
select = select
)
LibraryLayout(
books = category.value,
gridSize = gridSize,
autoGridSize = autoGridSize,
layout = layout
) { book ->
LibraryItem(
book = book,
layout = layout,
hasSelectedItems = hasSelectedItems,
titlePosition = titlePosition,
readButton = readButton,
showProgress = showProgress,
selectBook = { select ->
selectBook(
LibraryEvent.OnSelectBook(
id = book.data.id,
select = select
)
},
navigateToBookInfo = { navigateToBookInfo(book.data.id) },
navigateToReader = { navigateToReader(book.data.id) },
)
}
)
},
navigateToBookInfo = { navigateToBookInfo(book.data.id) },
navigateToReader = { navigateToReader(book.data.id) },
)
}
}
LibraryEmptyPlaceholder(
isLoading = isLoading,
isRefreshing = isRefreshing,
isBooksEmpty = category.value.books.isEmpty(),
isBooksEmpty = category.value.isEmpty(),
navigateToBrowse = navigateToBrowse
)
}

View file

@ -18,14 +18,25 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.ui.library.LibraryEvent
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LibraryScaffold(
books: List<SelectableBook>,
selectedItemsCount: Int,
hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
showCategoryTabs: Boolean,
showBookCount: Boolean,
showSearch: Boolean,
searchQuery: String,
bookCount: Int,
@ -34,7 +45,15 @@ fun LibraryScaffold(
isLoading: Boolean,
isRefreshing: Boolean,
refreshState: PullRefreshState,
categories: List<CategoryWithBooks>,
layout: LibraryLayout,
gridSize: Int,
autoGridSize: Boolean,
categories: List<Category>,
showDefaultCategory: Boolean,
perCategorySort: Boolean,
categoriesSort: List<CategorySort>,
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
searchVisibility: (LibraryEvent.OnSearchVisibility) -> Unit,
selectBook: (LibraryEvent.OnSelectBook) -> Unit,
requestFocus: (LibraryEvent.OnRequestFocus) -> Unit,
@ -43,6 +62,7 @@ fun LibraryScaffold(
clearSelectedBooks: (LibraryEvent.OnClearSelectedBooks) -> Unit,
showMoveDialog: (LibraryEvent.OnShowMoveDialog) -> Unit,
showDeleteDialog: (LibraryEvent.OnShowDeleteDialog) -> Unit,
showFilterBottomSheet: (LibraryEvent.OnShowFilterBottomSheet) -> Unit,
navigateToBrowse: () -> Unit,
navigateToBookInfo: (id: Int) -> Unit,
navigateToReader: (id: Int) -> Unit,
@ -54,8 +74,11 @@ fun LibraryScaffold(
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
LibraryTopBar(
books = books,
selectedItemsCount = selectedItemsCount,
hasSelectedItems = hasSelectedItems,
showBookCount = showBookCount,
showCategoryTabs = showCategoryTabs,
showSearch = showSearch,
searchQuery = searchQuery,
bookCount = bookCount,
@ -64,13 +87,15 @@ fun LibraryScaffold(
isLoading = isLoading,
isRefreshing = isRefreshing,
categories = categories,
showDefaultCategory = showDefaultCategory,
searchVisibility = searchVisibility,
requestFocus = requestFocus,
searchQueryChange = searchQueryChange,
search = search,
clearSelectedBooks = clearSelectedBooks,
showMoveDialog = showMoveDialog,
showDeleteDialog = showDeleteDialog
showDeleteDialog = showDeleteDialog,
showFilterBottomSheet = showFilterBottomSheet
)
}
) { paddingValues ->
@ -80,15 +105,27 @@ fun LibraryScaffold(
.padding(top = paddingValues.calculateTopPadding())
) {
LibraryPager(
books = books,
pagerState = pagerState,
categories = categories,
showDefaultCategory = showDefaultCategory,
perCategorySort = perCategorySort,
categoriesSort = categoriesSort,
sortOrder = sortOrder,
sortOrderDescending = sortOrderDescending,
layout = layout,
gridSize = gridSize,
autoGridSize = autoGridSize,
hasSelectedItems = hasSelectedItems,
titlePosition = titlePosition,
readButton = readButton,
showProgress = showProgress,
isLoading = isLoading,
isRefreshing = isRefreshing,
selectBook = selectBook,
navigateToBrowse = navigateToBrowse,
navigateToReader = navigateToReader,
navigateToBookInfo = navigateToBookInfo,
navigateToBookInfo = navigateToBookInfo
)
LibraryRefreshIndicator(

View file

@ -29,14 +29,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun LibraryTabs(
categories: List<CategoryWithBooks>,
categoriesWithBookCount: List<Pair<Category, Int>>,
pagerState: PagerState,
itemCountBackgroundColor: Color,
showBookCount: Boolean
) {
val scope = rememberCoroutineScope()
@ -69,7 +70,7 @@ fun LibraryTabs(
}
}
) {
categories.forEachIndexed { index, tabItem ->
categoriesWithBookCount.forEachIndexed { index, tabItem ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
@ -82,23 +83,26 @@ fun LibraryTabs(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
StyledText(
text = tabItem.title.asString(),
text = tabItem.first.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
)
Spacer(modifier = Modifier.width(6.dp))
StyledText(
text = tabItem.books.count().toString(),
modifier = Modifier
.background(
itemCountBackgroundColor,
MaterialTheme.shapes.medium
if (showBookCount) {
Spacer(modifier = Modifier.width(6.dp))
StyledText(
text = tabItem.second.toString(),
modifier = Modifier
.background(
itemCountBackgroundColor,
MaterialTheme.shapes.medium
)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}
)

View file

@ -9,6 +9,8 @@ package ua.acclorite.book_story.presentation.library
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -19,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.MoveUp
@ -26,6 +29,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -35,7 +40,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.presentation.core.components.common.AnimatedVisibility
import ua.acclorite.book_story.presentation.core.components.common.IconButton
import ua.acclorite.book_story.presentation.core.components.common.SearchTextField
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@ -47,8 +54,11 @@ import ua.acclorite.book_story.ui.library.LibraryEvent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryTopBar(
books: List<SelectableBook>,
selectedItemsCount: Int,
hasSelectedItems: Boolean,
showBookCount: Boolean,
showCategoryTabs: Boolean,
showSearch: Boolean,
searchQuery: String,
bookCount: Int,
@ -56,15 +66,49 @@ fun LibraryTopBar(
pagerState: PagerState,
isLoading: Boolean,
isRefreshing: Boolean,
categories: List<CategoryWithBooks>,
categories: List<Category>,
showDefaultCategory: Boolean,
searchVisibility: (LibraryEvent.OnSearchVisibility) -> Unit,
requestFocus: (LibraryEvent.OnRequestFocus) -> Unit,
searchQueryChange: (LibraryEvent.OnSearchQueryChange) -> Unit,
search: (LibraryEvent.OnSearch) -> Unit,
clearSelectedBooks: (LibraryEvent.OnClearSelectedBooks) -> Unit,
showMoveDialog: (LibraryEvent.OnShowMoveDialog) -> Unit,
showDeleteDialog: (LibraryEvent.OnShowDeleteDialog) -> Unit
showDeleteDialog: (LibraryEvent.OnShowDeleteDialog) -> Unit,
showFilterBottomSheet: (LibraryEvent.OnShowFilterBottomSheet) -> Unit
) {
val defaultCategory = stringResource(id = R.string.default_tab)
val categoriesWithBookCount = remember(
books,
categories,
showDefaultCategory,
defaultCategory
) {
derivedStateOf {
categories.map { category ->
category to books.count { it.data.categories.any { it == category.id } }
}.toMutableList().apply {
if (showDefaultCategory) {
val categoryIds = categories.map { it.id }.toSet()
add(
0,
Category(
id = -1,
title = defaultCategory
) to books.count { book ->
book.data.categories.none { category -> category in categoryIds }
}
)
}
}.toList()
}
}
val currentCategory = remember(pagerState.currentPage, categoriesWithBookCount) {
derivedStateOf {
categoriesWithBookCount.value[pagerState.currentPage]
}
}
val animatedItemCountBackgroundColor = animateColorAsState(
if (hasSelectedItems) MaterialTheme.colorScheme.surfaceContainerHighest
else MaterialTheme.colorScheme.surfaceContainer,
@ -86,21 +130,28 @@ fun LibraryTopBar(
contentNavigationIcon = {},
contentTitle = {
Row(verticalAlignment = Alignment.CenterVertically) {
StyledText(text = stringResource(id = R.string.library_screen))
Spacer(modifier = Modifier.width(6.dp))
StyledText(
text = bookCount.toString(),
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 8.dp, vertical = 3.dp),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
)
text = if (showCategoryTabs) stringResource(id = R.string.library_screen)
else currentCategory.value.first.title
)
if (showBookCount) {
Spacer(modifier = Modifier.width(6.dp))
StyledText(
text = if (showCategoryTabs) bookCount.toString()
else currentCategory.value.second.toString(),
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 8.dp, vertical = 3.dp),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
)
)
}
}
},
contentActions = {
@ -111,6 +162,13 @@ fun LibraryTopBar(
) {
searchVisibility(LibraryEvent.OnSearchVisibility(true))
}
IconButton(
icon = Icons.Default.FilterList,
contentDescription = R.string.filter_content_desc,
disableOnClick = false,
) {
showFilterBottomSheet(LibraryEvent.OnShowFilterBottomSheet)
}
NavigatorIconButton()
}
),
@ -188,11 +246,18 @@ fun LibraryTopBar(
),
),
customContent = {
LibraryTabs(
categories = categories,
pagerState = pagerState,
itemCountBackgroundColor = animatedItemCountBackgroundColor.value
)
AnimatedVisibility(
visible = showCategoryTabs,
enter = expandVertically(),
exit = shrinkVertically()
) {
LibraryTabs(
categoriesWithBookCount = categoriesWithBookCount.value,
pagerState = pagerState,
itemCountBackgroundColor = animatedItemCountBackgroundColor.value,
showBookCount = showBookCount
)
}
}
)
}

View file

@ -19,6 +19,7 @@ fun SettingsContent(
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
navigateToLibrarySettings: () -> Unit,
navigateToBrowseSettings: () -> Unit,
navigateBack: () -> Unit
) {
@ -28,6 +29,7 @@ fun SettingsContent(
navigateToGeneralSettings = navigateToGeneralSettings,
navigateToAppearanceSettings = navigateToAppearanceSettings,
navigateToReaderSettings = navigateToReaderSettings,
navigateToLibrarySettings = navigateToLibrarySettings,
navigateToBrowseSettings = navigateToBrowseSettings,
navigateBack = navigateBack
)

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.DisplaySettings
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocalLibrary
@ -29,6 +30,7 @@ fun SettingsLayout(
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
navigateToLibrarySettings: () -> Unit,
navigateToBrowseSettings: () -> Unit
) {
LazyColumnWithScrollbar(
@ -74,6 +76,17 @@ fun SettingsLayout(
item {
SettingsLayoutItem(
index = 3,
icon = Icons.Outlined.CollectionsBookmark,
title = stringResource(id = R.string.library_settings),
description = stringResource(id = R.string.library_settings_desc)
) {
navigateToLibrarySettings()
}
}
item {
SettingsLayoutItem(
index = 4,
icon = Icons.Outlined.Explore,
title = stringResource(id = R.string.browse_settings),
description = stringResource(id = R.string.browse_settings_desc)

View file

@ -27,6 +27,7 @@ fun SettingsScaffold(
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
navigateToLibrarySettings: () -> Unit,
navigateToBrowseSettings: () -> Unit,
navigateBack: () -> Unit
) {
@ -49,6 +50,7 @@ fun SettingsScaffold(
navigateToGeneralSettings = navigateToGeneralSettings,
navigateToAppearanceSettings = navigateToAppearanceSettings,
navigateToReaderSettings = navigateToReaderSettings,
navigateToLibrarySettings = navigateToLibrarySettings,
navigateToBrowseSettings = navigateToBrowseSettings
)
}

View file

@ -10,15 +10,11 @@ package ua.acclorite.book_story.presentation.settings.browse
import androidx.compose.foundation.lazy.LazyListScope
import ua.acclorite.book_story.presentation.settings.browse.display.BrowseDisplaySubcategory
import ua.acclorite.book_story.presentation.settings.browse.filter.BrowseFilterSubcategory
import ua.acclorite.book_story.presentation.settings.browse.scan.BrowseScanSubcategory
import ua.acclorite.book_story.presentation.settings.browse.sort.BrowseSortSubcategory
fun LazyListScope.BrowseSettingsCategory() {
BrowseScanSubcategory()
BrowseDisplaySubcategory()
BrowseFilterSubcategory()
BrowseSortSubcategory(
BrowseDisplaySubcategory(
showDivider = false
)
}

View file

@ -25,12 +25,12 @@ fun BrowseGridSizeOption() {
ExpandingTransition(visible = state.value.browseLayout == BrowseLayout.GRID) {
SliderWithTitle(
value = state.value.browseGridSize
to " ${stringResource(R.string.browse_grid_size_per_row)}",
valuePlaceholder = stringResource(id = R.string.browse_grid_size_auto),
to " ${stringResource(R.string.grid_size_per_row)}",
valuePlaceholder = stringResource(id = R.string.grid_size_auto),
showPlaceholder = state.value.browseAutoGridSize,
fromValue = 0,
toValue = 10,
title = stringResource(id = R.string.browse_grid_size_option),
title = stringResource(id = R.string.grid_size_option),
onValueChange = {
mainModel.onEvent(MainEvent.OnChangeBrowseAutoGridSize(it == 0))
mainModel.onEvent(

View file

@ -19,7 +19,7 @@ import ua.acclorite.book_story.presentation.settings.components.SettingsSubcateg
fun LazyListScope.BrowseSortSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.sort_browse_settings) },
title: @Composable () -> String = { stringResource(id = R.string.sort_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {

View file

@ -90,10 +90,10 @@ private fun BrowseSortOptionItem(
StyledText(
text = stringResource(
when (item) {
BrowseSortOrder.NAME -> R.string.browse_sort_order_name
BrowseSortOrder.FILE_FORMAT -> R.string.browse_sort_order_file_format
BrowseSortOrder.LAST_MODIFIED -> R.string.browse_sort_order_last_modified
BrowseSortOrder.FILE_SIZE -> R.string.browse_sort_order_file_size
BrowseSortOrder.NAME -> R.string.sort_order_name
BrowseSortOrder.FILE_FORMAT -> R.string.sort_order_file_format
BrowseSortOrder.LAST_MODIFIED -> R.string.sort_order_last_modified
BrowseSortOrder.FILE_SIZE -> R.string.sort_order_file_size
}
),
style = MaterialTheme.typography.bodyLarge,

View file

@ -0,0 +1,24 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library
import androidx.compose.foundation.lazy.LazyListScope
import ua.acclorite.book_story.presentation.settings.library.categories.LibraryCategoriesSubcategory
import ua.acclorite.book_story.presentation.settings.library.display.LibraryDisplaySubcategory
import ua.acclorite.book_story.presentation.settings.library.sort.LibrarySortSubcategory
import ua.acclorite.book_story.presentation.settings.library.tabs.LibraryTabsSubcategory
fun LazyListScope.LibrarySettingsCategory() {
LibraryCategoriesSubcategory()
LibraryDisplaySubcategory()
LibraryTabsSubcategory()
LibrarySortSubcategory(
showDivider = false
)
}

View file

@ -0,0 +1,26 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibrarySettingsContent(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
navigateBack: () -> Unit
) {
LibrarySettingsScaffold(
listState = listState,
scrollBehavior = scrollBehavior,
navigateBack = navigateBack
)
}

View file

@ -0,0 +1,30 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
@Composable
fun LibrarySettingsLayout(
listState: LazyListState,
paddingValues: PaddingValues
) {
LazyColumnWithScrollbar(
Modifier
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding()),
state = listState
) {
LibrarySettingsCategory()
}
}

View file

@ -0,0 +1,47 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibrarySettingsScaffold(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
navigateBack: () -> Unit
) {
Scaffold(
Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.windowInsetsPadding(WindowInsets.navigationBars),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
LibrarySettingsTopBar(
scrollBehavior = scrollBehavior,
navigateBack = navigateBack
)
}
) { paddingValues ->
LibrarySettingsLayout(
listState = listState,
paddingValues = paddingValues
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.navigator.NavigatorBackIconButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibrarySettingsTopBar(
scrollBehavior: TopAppBarScrollBehavior,
navigateBack: () -> Unit
) {
LargeTopAppBar(
title = {
StyledText(stringResource(id = R.string.library_settings))
},
navigationIcon = {
NavigatorBackIconButton(
navigateBack = navigateBack
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer
)
)
}

View file

@ -0,0 +1,41 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library.categories
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategory
import ua.acclorite.book_story.presentation.settings.library.categories.components.LibraryCategoriesOption
import ua.acclorite.book_story.presentation.settings.library.categories.components.LibraryCategoriesOptionNote
fun LazyListScope.LibraryCategoriesSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.categories_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {
SettingsSubcategory(
titleColor = titleColor,
title = title,
showTitle = showTitle,
showDivider = showDivider
) {
item {
LibraryCategoriesOption()
}
item {
LibraryCategoriesOptionNote()
}
}
}

View file

@ -0,0 +1,368 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.categories.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.automirrored.outlined.LabelOff
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import sh.calvin.reorderable.ReorderableColumn
import sh.calvin.reorderable.ReorderableScope
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.presentation.core.components.common.IconButton
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.components.dialog.Dialog
import ua.acclorite.book_story.presentation.core.components.dialog.DialogWithTextField
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.ui.library.LibraryScreen
import ua.acclorite.book_story.ui.settings.SettingsEvent
import ua.acclorite.book_story.ui.settings.SettingsModel
import ua.acclorite.book_story.ui.theme.ExpandingTransition
import ua.acclorite.book_story.ui.theme.dynamicListItemColor
private const val CREATE_DIALOG = "create_dialog"
private const val EDIT_DIALOG = "edit_dialog"
private const val REMOVE_DIALOG = "remove_dialog"
@Composable
fun LibraryCategoriesOption() {
val settingsModel = hiltViewModel<SettingsModel>()
val state = settingsModel.state.collectAsStateWithLifecycle()
val dialog = remember {
mutableStateOf<String?>(null)
}
val selectedCategory = remember {
mutableStateOf<Category?>(null)
}
when (dialog.value) {
CREATE_DIALOG -> {
LibraryCategoriesCreateDialog(
onDismiss = { dialog.value = null },
onAction = { title ->
settingsModel.onEvent(
SettingsEvent.OnCreateCategory(
title = title
)
)
}
)
}
EDIT_DIALOG -> {
LibraryCategoriesEditDialog(
initialValue = selectedCategory.value?.title ?: "",
onDismiss = { dialog.value = null },
onAction = { title ->
settingsModel.onEvent(
SettingsEvent.OnUpdateCategoryTitle(
id = selectedCategory.value?.id ?: -1,
title = title
)
)
}
)
}
REMOVE_DIALOG -> {
LibraryCategoriesRemoveDialog(
onDismiss = { dialog.value = null },
onAction = {
if (selectedCategory.value != null) {
LibraryScreen.scrollToPageCompositionChannel.trySend(0)
settingsModel.onEvent(
SettingsEvent.OnRemoveCategory(selectedCategory.value!!)
)
}
dialog.value = null
}
)
}
}
ReorderableColumn(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
list = state.value.categories,
onSettle = { from, to ->
LibraryScreen.scrollToPageCompositionChannel.trySend(0)
settingsModel.onEvent(
SettingsEvent.OnUpdateCategoryOrder(
state.value.categories.toMutableList().apply {
add(to, removeAt(from))
}
)
)
}
) { _, category, _ ->
key(category.id) {
LibraryCategoryItem(
category = category,
edit = {
selectedCategory.value = category
dialog.value = EDIT_DIALOG
},
remove = {
selectedCategory.value = category
dialog.value = REMOVE_DIALOG
}
)
}
}
LibraryCategoriesAction {
dialog.value = CREATE_DIALOG
}
}
@Composable
private fun LibraryCategoriesEditDialog(
initialValue: String,
onDismiss: () -> Unit,
onAction: (title: String) -> Unit
) {
DialogWithTextField(
initialValue = initialValue,
lengthLimit = 200,
onDismiss = {
onDismiss()
},
onAction = {
if (it.isBlank()) return@DialogWithTextField
onAction(it.replace("\n", "").trim())
}
)
}
@Composable
private fun LibraryCategoriesRemoveDialog(
onDismiss: () -> Unit,
onAction: () -> Unit
) {
Dialog(
title = stringResource(id = R.string.remove_category),
icon = Icons.AutoMirrored.Outlined.LabelOff,
description = stringResource(id = R.string.remove_category_description),
actionEnabled = true,
onDismiss = {
onDismiss()
},
onAction = {
onAction()
},
withContent = false
)
}
@Composable
private fun LibraryCategoriesCreateDialog(
onDismiss: () -> Unit,
onAction: (title: String) -> Unit
) {
val title = remember {
mutableStateOf("")
}
val error = remember {
mutableStateOf(false)
}
val focusRequester = remember { FocusRequester() }
val focused = remember { mutableStateOf(false) }
Dialog(
title = stringResource(id = R.string.create_category),
description = null,
onDismiss = { onDismiss() },
actionEnabled = true,
disableOnClick = false,
onAction = {
if (title.value.isBlank()) {
error.value = true
return@Dialog
}
onAction(title.value.replace("\n", "").trim())
onDismiss()
},
withContent = true,
items = {
item {
OutlinedTextField(
value = title.value,
onValueChange = {
error.value = false
if (it.length <= 200) {
title.value = it
}
},
keyboardOptions = KeyboardOptions(KeyboardCapitalization.Sentences),
isError = error.value,
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
if (!focused.value) {
focusRequester.requestFocus()
focused.value = true
}
},
colors = TextFieldDefaults.colors(
errorContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
label = {
StyledText(
text = stringResource(
id = R.string.library_categories_create_category_placeholder
),
maxLines = 1
)
},
supportingText = {
ExpandingTransition(error.value) {
StyledText(
text = stringResource(id = R.string.error_field_empty),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.error
)
)
}
}
)
}
}
)
}
@Composable
private fun LibraryCategoriesAction(
create: () -> Unit
) {
Row(
modifier = Modifier
.padding(18.dp)
.noRippleClickable {
create()
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)
StyledText(
text = stringResource(id = R.string.create_category),
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.secondary
)
)
}
}
@Composable
private fun ReorderableScope.LibraryCategoryItem(
category: Category,
edit: () -> Unit,
remove: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 18.dp,
vertical = 8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Label,
contentDescription = stringResource(id = R.string.drag_content_desc),
modifier = Modifier
.clip(CircleShape)
.draggableHandle()
.background(MaterialTheme.colorScheme.dynamicListItemColor(category.order))
.padding(11.dp)
.size(22.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(18.dp))
StyledText(
text = category.title,
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(18.dp))
IconButton(
modifier = Modifier.size(24.dp),
icon = Icons.Outlined.EditNote,
contentDescription = R.string.edit_content_desc,
disableOnClick = false,
color = MaterialTheme.colorScheme.onSurface
) {
edit()
}
IconButton(
modifier = Modifier.size(24.dp),
icon = Icons.Outlined.Clear,
contentDescription = R.string.remove_content_desc,
disableOnClick = false,
color = MaterialTheme.colorScheme.onSurface
) {
remove()
}
}
}

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.categories.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategoryNote
@Composable
fun LibraryCategoriesOptionNote() {
SettingsSubcategoryNote(
text = stringResource(id = R.string.library_categories_option_note)
)
}

View file

@ -0,0 +1,56 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library.display
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategory
import ua.acclorite.book_story.presentation.settings.library.display.components.LibraryGridSizeOption
import ua.acclorite.book_story.presentation.settings.library.display.components.LibraryLayoutOption
import ua.acclorite.book_story.presentation.settings.library.display.components.LibraryReadButtonOption
import ua.acclorite.book_story.presentation.settings.library.display.components.LibraryShowProgressOption
import ua.acclorite.book_story.presentation.settings.library.display.components.LibraryTitlePositionOption
fun LazyListScope.LibraryDisplaySubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.display_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {
SettingsSubcategory(
titleColor = titleColor,
title = title,
showTitle = showTitle,
showDivider = showDivider
) {
item {
LibraryLayoutOption()
}
item {
LibraryGridSizeOption()
}
item {
LibraryTitlePositionOption()
}
item {
LibraryReadButtonOption()
}
item {
LibraryShowProgressOption()
}
}
}

View file

@ -0,0 +1,42 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.display.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.presentation.core.components.settings.SliderWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
import ua.acclorite.book_story.ui.theme.ExpandingTransition
@Composable
fun LibraryGridSizeOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
ExpandingTransition(visible = state.value.libraryLayout == LibraryLayout.GRID) {
SliderWithTitle(
value = state.value.libraryGridSize
to " ${stringResource(R.string.grid_size_per_row)}",
valuePlaceholder = stringResource(id = R.string.grid_size_auto),
showPlaceholder = state.value.libraryAutoGridSize,
fromValue = 0,
toValue = 10,
title = stringResource(id = R.string.grid_size_option),
onValueChange = {
mainModel.onEvent(MainEvent.OnChangeLibraryAutoGridSize(it == 0))
mainModel.onEvent(
MainEvent.OnChangeLibraryGridSize(it)
)
}
)
}
}

View file

@ -0,0 +1,46 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.display.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.ui.ButtonItem
import ua.acclorite.book_story.presentation.core.components.settings.SegmentedButtonWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryLayoutOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SegmentedButtonWithTitle(
title = stringResource(id = R.string.layout_option),
buttons = LibraryLayout.entries.map {
ButtonItem(
it.toString(),
when (it) {
LibraryLayout.LIST -> stringResource(id = R.string.layout_list)
LibraryLayout.GRID -> stringResource(id = R.string.layout_grid)
},
MaterialTheme.typography.labelLarge,
it == state.value.libraryLayout
)
}
) {
mainModel.onEvent(
MainEvent.OnChangeLibraryLayout(
it.id
)
)
}
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.display.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryReadButtonOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryReadButton,
title = stringResource(id = R.string.read_button_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryReadButton(
!state.value.libraryReadButton
)
)
}
)
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.display.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryShowProgressOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryShowProgress,
title = stringResource(id = R.string.show_progress_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryShowProgress(
!state.value.libraryShowProgress
)
)
}
)
}

View file

@ -0,0 +1,59 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.display.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.domain.ui.ButtonItem
import ua.acclorite.book_story.presentation.core.components.settings.ChipsWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
import ua.acclorite.book_story.ui.theme.ExpandingTransition
@Composable
fun LibraryTitlePositionOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
ExpandingTransition(visible = state.value.libraryLayout == LibraryLayout.GRID) {
ChipsWithTitle(
title = stringResource(id = R.string.title_position_option),
chips = LibraryTitlePosition.entries.map {
ButtonItem(
it.toString(),
when (it) {
LibraryTitlePosition.OFF -> {
stringResource(id = R.string.library_title_position_off)
}
LibraryTitlePosition.BELOW -> {
stringResource(id = R.string.library_title_position_below)
}
LibraryTitlePosition.INSIDE -> {
stringResource(id = R.string.library_title_position_inside)
}
},
MaterialTheme.typography.labelLarge,
it == state.value.libraryTitlePosition
)
}
) {
mainModel.onEvent(
MainEvent.OnChangeLibraryTitlePosition(
it.id
)
)
}
}
}

View file

@ -0,0 +1,36 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library.sort
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategory
import ua.acclorite.book_story.presentation.settings.library.sort.components.LibraryPerCategorySortOption
fun LazyListScope.LibrarySortSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.sort_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {
SettingsSubcategory(
titleColor = titleColor,
title = title,
showTitle = showTitle,
showDivider = showDivider
) {
item {
LibraryPerCategorySortOption()
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.sort.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryPerCategorySortOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryPerCategorySort,
title = stringResource(id = R.string.per_category_sort_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryPerCategorySort(
!state.value.libraryPerCategorySort
)
)
}
)
}

View file

@ -0,0 +1,95 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library.sort.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.presentation.core.components.common.StyledText
fun LazyListScope.LibrarySortOption(
sortOrder: LibrarySortOrder,
sortOrderDescending: Boolean,
onChange: (LibrarySortOrder, Boolean) -> Unit
) {
items(LibrarySortOrder.entries, key = { it.name }) {
LibrarySortOptionItem(
item = it,
isSelected = sortOrder == it,
isDescending = sortOrderDescending
) {
if (sortOrder == it) {
onChange(sortOrder, !sortOrderDescending)
} else {
onChange(it, true)
}
}
}
}
@Composable
private fun LibrarySortOptionItem(
item: LibrarySortOrder,
isSelected: Boolean,
isDescending: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onClick()
}
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isDescending) Icons.Default.ArrowDownward
else Icons.Default.ArrowUpward,
contentDescription = stringResource(id = R.string.sort_order_content_desc),
modifier = Modifier
.size(28.dp),
tint = if (isSelected) MaterialTheme.colorScheme.secondary
else Color.Transparent
)
Spacer(modifier = Modifier.width(24.dp))
StyledText(
text = stringResource(
when (item) {
LibrarySortOrder.NAME -> R.string.sort_order_name
LibrarySortOrder.LAST_READ -> R.string.sort_order_last_read
LibrarySortOrder.PROGRESS -> R.string.sort_order_progress
LibrarySortOrder.AUTHOR -> R.string.sort_order_author
}
),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
}
}

View file

@ -0,0 +1,46 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.library.tabs
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategory
import ua.acclorite.book_story.presentation.settings.library.tabs.components.LibraryAlwaysShowDefaultTabOption
import ua.acclorite.book_story.presentation.settings.library.tabs.components.LibraryShowBookCountOption
import ua.acclorite.book_story.presentation.settings.library.tabs.components.LibraryShowCategoryTabsOption
fun LazyListScope.LibraryTabsSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.tabs_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {
SettingsSubcategory(
titleColor = titleColor,
title = title,
showTitle = showTitle,
showDivider = showDivider
) {
item {
LibraryShowCategoryTabsOption()
}
item {
LibraryAlwaysShowDefaultTabOption()
}
item {
LibraryShowBookCountOption()
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.tabs.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryAlwaysShowDefaultTabOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryAlwaysShowDefaultTab,
title = stringResource(id = R.string.always_show_default_tab_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryAlwaysShowDefaultTab(
!state.value.libraryAlwaysShowDefaultTab
)
)
}
)
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.tabs.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryShowBookCountOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryShowBookCount,
title = stringResource(id = R.string.show_book_count_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryShowBookCount(
!state.value.libraryShowBookCount
)
)
}
)
}

View file

@ -0,0 +1,34 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.settings.library.tabs.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.settings.SwitchWithTitle
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.main.MainModel
@Composable
fun LibraryShowCategoryTabsOption() {
val mainModel = hiltViewModel<MainModel>()
val state = mainModel.state.collectAsStateWithLifecycle()
SwitchWithTitle(
selected = state.value.libraryShowCategoryTabs,
title = stringResource(id = R.string.show_category_tabs_option),
onClick = {
mainModel.onEvent(
MainEvent.OnChangeLibraryShowCategoryTabs(
!state.value.libraryShowCategoryTabs
)
)
}
)
}

View file

@ -74,9 +74,8 @@ sealed class BookInfoEvent {
data object OnShowMoveDialog : BookInfoEvent()
data class OnActionMoveDialog(
val category: Category,
val context: Context,
val navigateToLibrary: () -> Unit
val selectedCategories: List<Category>,
val context: Context
) : BookInfoEvent()
data object OnDismissDialog : BookInfoEvent()

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.use_case.book.CanResetCover
import ua.acclorite.book_story.domain.use_case.book.DeleteBooks
import ua.acclorite.book_story.domain.use_case.book.GetBookById
@ -357,34 +356,22 @@ class BookInfoModel @Inject constructor(
launch {
_state.update {
it.copy(
book = it.book.copy(
categories = event.selectedCategories.map { it.id }
),
dialog = null,
bottomSheet = null
)
}
_state.update {
it.copy(
book = it.book.copy(
category = event.category
)
)
}
updateBook.execute(_state.value.book)
LibraryScreen.refreshListChannel.trySend(0)
LibraryScreen.scrollToPageCompositionChannel.trySend(
Category.entries.dropLastWhile {
it != event.category
}.size - 1
)
HistoryScreen.refreshListChannel.trySend(0)
withContext(Dispatchers.Main) {
event.context.getString(R.string.book_moved)
.showToast(context = event.context)
}
event.navigateToLibrary()
}
}

View file

@ -23,8 +23,9 @@ import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.presentation.book_info.BookInfoContent
import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.ui.history.HistoryScreen
import ua.acclorite.book_story.ui.library.LibraryScreen
import ua.acclorite.book_story.ui.reader.ReaderScreen
import ua.acclorite.book_story.ui.settings.LibrarySettingsScreen
import ua.acclorite.book_story.ui.settings.SettingsModel
@Parcelize
data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
@ -47,7 +48,9 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
override fun Content() {
val navigator = LocalNavigator.current
val screenModel = hiltViewModel<BookInfoModel>()
val settingsModel = hiltViewModel<SettingsModel>()
val settingsState = settingsModel.state.collectAsStateWithLifecycle()
val state = screenModel.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
@ -72,6 +75,7 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
if (state.value.book.id == bookId) {
BookInfoContent(
book = state.value.book,
categories = settingsState.value.categories,
bottomSheet = state.value.bottomSheet,
dialog = state.value.dialog,
listState = listState,
@ -102,12 +106,8 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
navigator.push(ReaderScreen(state.value.book.id))
}
},
navigateToLibrary = {
navigator.push(
LibraryScreen,
popping = true,
saveInBackStack = false
)
navigateToLibrarySettings = {
navigator.push(LibrarySettingsScreen)
},
navigateBack = {
navigator.pop()

View file

@ -29,6 +29,7 @@ import ua.acclorite.book_story.domain.library.book.SelectableNullableBook
import ua.acclorite.book_story.domain.use_case.book.InsertBook
import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.file_system.GetFiles
import ua.acclorite.book_story.presentation.core.util.compareByWithOrder
import ua.acclorite.book_story.presentation.core.util.showToast
import ua.acclorite.book_story.ui.library.LibraryScreen
import javax.inject.Inject
@ -419,16 +420,6 @@ class BrowseModel @Inject constructor(
includedFilterItems: List<String>,
sortOrder: BrowseSortOrder
): List<SelectableFile> {
fun <T> compareByWithOrder(
selector: (T) -> Comparable<*>?
): Comparator<T> {
return if (sortOrderDescending) {
compareByDescending(selector)
} else {
compareBy(selector)
}
}
fun List<SelectableFile>.filterFiles(): List<SelectableFile> {
if (includedFilterItems.isEmpty()) {
return this
@ -446,7 +437,7 @@ class BrowseModel @Inject constructor(
return files
.filterFiles()
.sortedWith(
compareByWithOrder {
compareByWithOrder(sortOrderDescending) {
when (sortOrder) {
BrowseSortOrder.NAME -> {
it.data.name.trim()

View file

@ -10,7 +10,6 @@ import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
@Immutable
sealed class LibraryEvent {
@ -43,8 +42,7 @@ sealed class LibraryEvent {
data object OnShowMoveDialog : LibraryEvent()
data class OnActionMoveDialog(
val selectedCategory: Category,
val categories: List<CategoryWithBooks>,
val selectedCategories: List<Category>,
val context: Context
) : LibraryEvent()
@ -55,4 +53,8 @@ sealed class LibraryEvent {
) : LibraryEvent()
data object OnDismissDialog : LibraryEvent()
data object OnShowFilterBottomSheet : LibraryEvent()
data object OnDismissBottomSheet : LibraryEvent()
}

View file

@ -210,7 +210,7 @@ class LibraryModel @Inject constructor(
if (!book.selected) return@forEach
moveBooks.execute(
book.data.copy(
category = event.selectedCategory
categories = event.selectedCategories.map { it.id }
)
)
}
@ -221,7 +221,7 @@ class LibraryModel @Inject constructor(
if (!book.selected) return@map book
book.copy(
data = book.data.copy(
category = event.selectedCategory
categories = event.selectedCategories.map { it.id }
),
selected = false
)
@ -232,11 +232,6 @@ class LibraryModel @Inject constructor(
}
HistoryScreen.refreshListChannel.trySend(0)
LibraryScreen.scrollToPageCompositionChannel.trySend(
event.categories.dropLastWhile {
it.category != event.selectedCategory
}.lastIndex
)
withContext(Dispatchers.Main) {
event.context
@ -293,6 +288,26 @@ class LibraryModel @Inject constructor(
}
}
}
is LibraryEvent.OnShowFilterBottomSheet -> {
viewModelScope.launch {
_state.update {
it.copy(
bottomSheet = LibraryScreen.FILTER_BOTTOM_SHEET
)
}
}
}
is LibraryEvent.OnDismissBottomSheet -> {
viewModelScope.launch {
_state.update {
it.copy(
bottomSheet = null
)
}
}
}
}
}

View file

@ -23,11 +23,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.presentation.library.LibraryContent
import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.ui.book_info.BookInfoScreen
@ -35,6 +31,8 @@ import ua.acclorite.book_story.ui.browse.BrowseScreen
import ua.acclorite.book_story.ui.history.HistoryScreen
import ua.acclorite.book_story.ui.main.MainModel
import ua.acclorite.book_story.ui.reader.ReaderScreen
import ua.acclorite.book_story.ui.settings.LibrarySettingsScreen
import ua.acclorite.book_story.ui.settings.SettingsModel
@Parcelize
object LibraryScreen : Screen, Parcelable {
@ -45,6 +43,9 @@ object LibraryScreen : Screen, Parcelable {
@IgnoredOnParcel
const val DELETE_DIALOG = "delete_dialog"
@IgnoredOnParcel
const val FILTER_BOTTOM_SHEET = "filter_bottom_sheet"
@IgnoredOnParcel
val refreshListChannel: Channel<Long> = Channel(Channel.CONFLATED)
@ -61,34 +62,19 @@ object LibraryScreen : Screen, Parcelable {
val screenModel = hiltViewModel<LibraryModel>()
val mainModel = hiltViewModel<MainModel>()
val settingsModel = hiltViewModel<SettingsModel>()
val state = screenModel.state.collectAsStateWithLifecycle()
val mainState = mainModel.state.collectAsStateWithLifecycle()
val settingsState = settingsModel.state.collectAsStateWithLifecycle()
val categories = remember(state.value.books) {
val showDefaultCategory = remember(state.value.books, settingsState.value.categories) {
derivedStateOf {
listOf(
CategoryWithBooks(
category = Category.READING,
title = UIText.StringResource(R.string.reading_tab),
books = state.value.books.filter { it.data.category == Category.READING }
),
CategoryWithBooks(
category = Category.ALREADY_READ,
title = UIText.StringResource(R.string.already_read_tab),
books = state.value.books.filter { it.data.category == Category.ALREADY_READ }
),
CategoryWithBooks(
category = Category.PLANNING,
title = UIText.StringResource(R.string.planning_tab),
books = state.value.books.filter { it.data.category == Category.PLANNING }
),
CategoryWithBooks(
category = Category.DROPPED,
title = UIText.StringResource(R.string.dropped_tab),
books = state.value.books.filter { it.data.category == Category.DROPPED }
)
)
val categoryIds = settingsState.value.categories.map { it.id }.toSet()
state.value.books.any { book ->
book.data.categories.none { category -> category in categoryIds }
} || settingsState.value.categories.isEmpty()
|| mainState.value.libraryAlwaysShowDefaultTab
}
}
@ -107,12 +93,14 @@ object LibraryScreen : Screen, Parcelable {
val pagerState = rememberPagerState(
initialPage = initialPage
) { categories.value.count() }
) {
settingsState.value.categories.count().plus(if (showDefaultCategory.value) 1 else 0)
}
DisposableEffect(Unit) { onDispose { initialPage = pagerState.currentPage } }
LaunchedEffect(Unit) {
scrollToPageCompositionChannel.receiveAsFlow().collectLatest {
pagerState.animateScrollToPage(it)
pagerState.scrollToPage(it)
}
}
@ -120,6 +108,11 @@ object LibraryScreen : Screen, Parcelable {
books = state.value.books,
selectedItemsCount = state.value.selectedItemsCount,
hasSelectedItems = state.value.hasSelectedItems,
titlePosition = mainState.value.libraryTitlePosition,
readButton = mainState.value.libraryReadButton,
showProgress = mainState.value.libraryShowProgress,
showCategoryTabs = mainState.value.libraryShowCategoryTabs,
showBookCount = mainState.value.libraryShowBookCount,
showSearch = state.value.showSearch,
searchQuery = state.value.searchQuery,
bookCount = state.value.books.count(),
@ -128,9 +121,21 @@ object LibraryScreen : Screen, Parcelable {
isLoading = state.value.isLoading,
isRefreshing = state.value.isRefreshing,
doublePressExit = mainState.value.doublePressExit,
categories = categories.value,
layout = mainState.value.libraryLayout,
gridSize = mainState.value.libraryGridSize,
autoGridSize = mainState.value.libraryAutoGridSize,
categories = settingsState.value.categories,
showDefaultCategory = showDefaultCategory.value,
categoriesSort = settingsState.value.categoriesSort,
sortOrder = mainState.value.librarySortOrder,
sortOrderDescending = mainState.value.librarySortOrderDescending,
perCategorySort = mainState.value.libraryPerCategorySort,
refreshState = refreshState,
dialog = state.value.dialog,
bottomSheet = state.value.bottomSheet,
updateCategorySort = settingsModel::onEvent,
changeLibrarySortOrder = mainModel::onEvent,
changeLibrarySortOrderDescending = mainModel::onEvent,
selectBook = screenModel::onEvent,
searchVisibility = screenModel::onEvent,
requestFocus = screenModel::onEvent,
@ -141,6 +146,8 @@ object LibraryScreen : Screen, Parcelable {
actionMoveDialog = screenModel::onEvent,
actionDeleteDialog = screenModel::onEvent,
showDeleteDialog = screenModel::onEvent,
showFilterBottomSheet = screenModel::onEvent,
dismissBottomSheet = screenModel::onEvent,
dismissDialog = screenModel::onEvent,
navigateToBrowse = {
navigator.push(BrowseScreen)
@ -151,6 +158,9 @@ object LibraryScreen : Screen, Parcelable {
},
navigateToBookInfo = {
navigator.push(BookInfoScreen(bookId = it))
},
navigateToLibrarySettings = {
navigator.push(LibrarySettingsScreen)
}
)
}

View file

@ -8,6 +8,7 @@ package ua.acclorite.book_story.ui.library
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.library.book.SelectableBook
import ua.acclorite.book_story.domain.util.BottomSheet
import ua.acclorite.book_story.domain.util.Dialog
@Immutable
@ -24,5 +25,6 @@ data class LibraryState(
val searchQuery: String = "",
val hasFocused: Boolean = false,
val dialog: Dialog? = null
val dialog: Dialog? = null,
val bottomSheet: BottomSheet? = null
)

View file

@ -67,4 +67,16 @@ sealed class MainEvent {
data class OnChangeProgressCount(val value: String) : MainEvent()
data class OnChangeHorizontalGestureAlphaAnim(val value: Boolean) : MainEvent()
data class OnChangeHorizontalGesturePullAnim(val value: Boolean) : MainEvent()
data class OnChangeLibraryLayout(val value: String) : MainEvent()
data class OnChangeLibraryAutoGridSize(val value: Boolean) : MainEvent()
data class OnChangeLibraryGridSize(val value: Int) : MainEvent()
data class OnChangeLibraryReadButton(val value: Boolean) : MainEvent()
data class OnChangeLibraryShowProgress(val value: Boolean) : MainEvent()
data class OnChangeLibraryTitlePosition(val value: String) : MainEvent()
data class OnChangeLibraryShowBookCount(val value: Boolean) : MainEvent()
data class OnChangeLibraryShowCategoryTabs(val value: Boolean) : MainEvent()
data class OnChangeLibraryAlwaysShowDefaultTab(val value: Boolean) : MainEvent()
data class OnChangeLibrarySortOrder(val value: String) : MainEvent()
data class OnChangeLibrarySortOrderDescending(val value: Boolean) : MainEvent()
data class OnChangeLibraryPerCategorySort(val value: Boolean) : MainEvent()
}

View file

@ -24,6 +24,9 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.domain.browse.display.toBrowseLayout
import ua.acclorite.book_story.domain.browse.display.toBrowseSortOrder
import ua.acclorite.book_story.domain.library.display.toLibraryLayout
import ua.acclorite.book_story.domain.library.display.toLibrarySortOrder
import ua.acclorite.book_story.domain.library.display.toLibraryTitlePosition
import ua.acclorite.book_story.domain.reader.toColorEffects
import ua.acclorite.book_story.domain.reader.toFontThickness
import ua.acclorite.book_story.domain.reader.toHorizontalGesture
@ -514,6 +517,102 @@ class MainModel @Inject constructor(
it.copy(horizontalGesturePullAnim = this)
}
)
is MainEvent.OnChangeLibraryLayout -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_LAYOUT,
value = event.value,
updateState = {
it.copy(libraryLayout = this.toLibraryLayout())
}
)
is MainEvent.OnChangeLibraryAutoGridSize -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_AUTO_GRID_SIZE,
value = event.value,
updateState = {
it.copy(libraryAutoGridSize = this)
}
)
is MainEvent.OnChangeLibraryGridSize -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_GRID_SIZE,
value = event.value,
updateState = {
it.copy(libraryGridSize = this)
}
)
is MainEvent.OnChangeLibraryReadButton -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_READ_BUTTON,
value = event.value,
updateState = {
it.copy(libraryReadButton = this)
}
)
is MainEvent.OnChangeLibraryShowProgress -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_SHOW_PROGRESS,
value = event.value,
updateState = {
it.copy(libraryShowProgress = this)
}
)
is MainEvent.OnChangeLibraryTitlePosition -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_TITLE_POSITION,
value = event.value,
updateState = {
it.copy(libraryTitlePosition = this.toLibraryTitlePosition())
}
)
is MainEvent.OnChangeLibraryShowBookCount -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_SHOW_BOOK_COUNT,
value = event.value,
updateState = {
it.copy(libraryShowBookCount = this)
}
)
is MainEvent.OnChangeLibraryShowCategoryTabs -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_SHOW_CATEGORY_TABS,
value = event.value,
updateState = {
it.copy(libraryShowCategoryTabs = this)
}
)
is MainEvent.OnChangeLibraryAlwaysShowDefaultTab -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_ALWAYS_SHOW_DEFAULT_TAB,
value = event.value,
updateState = {
it.copy(libraryAlwaysShowDefaultTab = this)
}
)
is MainEvent.OnChangeLibrarySortOrder -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_SORT_ORDER,
value = event.value,
updateState = {
it.copy(librarySortOrder = this.toLibrarySortOrder())
}
)
is MainEvent.OnChangeLibrarySortOrderDescending -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_SORT_ORDER_DESCENDING,
value = event.value,
updateState = {
it.copy(librarySortOrderDescending = this)
}
)
is MainEvent.OnChangeLibraryPerCategorySort -> handleDatastoreUpdate(
key = DataStoreConstants.LIBRARY_PER_CATEGORY_SORT,
value = event.value,
updateState = {
it.copy(libraryPerCategorySort = this)
}
)
}
}

View file

@ -17,6 +17,12 @@ import ua.acclorite.book_story.domain.browse.display.BrowseLayout
import ua.acclorite.book_story.domain.browse.display.BrowseSortOrder
import ua.acclorite.book_story.domain.browse.display.toBrowseLayout
import ua.acclorite.book_story.domain.browse.display.toBrowseSortOrder
import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.domain.library.display.toLibraryLayout
import ua.acclorite.book_story.domain.library.display.toLibrarySortOrder
import ua.acclorite.book_story.domain.library.display.toLibraryTitlePosition
import ua.acclorite.book_story.domain.reader.ReaderColorEffects
import ua.acclorite.book_story.domain.reader.ReaderFontThickness
import ua.acclorite.book_story.domain.reader.ReaderHorizontalGesture
@ -117,6 +123,20 @@ data class MainState(
val progressBarFontSize: Int = provideDefaultValue { 8 },
val progressCount: ReaderProgressCount = provideDefaultValue { ReaderProgressCount.PERCENTAGE },
// Library settings
val libraryLayout: LibraryLayout = provideDefaultValue { LibraryLayout.GRID },
val libraryAutoGridSize: Boolean = provideDefaultValue { true },
val libraryGridSize: Int = provideDefaultValue { 0 },
val libraryTitlePosition: LibraryTitlePosition = provideDefaultValue { LibraryTitlePosition.BELOW },
val libraryReadButton: Boolean = provideDefaultValue { true },
val libraryShowProgress: Boolean = provideDefaultValue { true },
val libraryShowBookCount: Boolean = provideDefaultValue { true },
val libraryShowCategoryTabs: Boolean = provideDefaultValue { true },
val libraryAlwaysShowDefaultTab: Boolean = provideDefaultValue { false },
val librarySortOrder: LibrarySortOrder = provideDefaultValue { LibrarySortOrder.LAST_READ },
val librarySortOrderDescending: Boolean = provideDefaultValue { true },
val libraryPerCategorySort: Boolean = provideDefaultValue { false },
// Browse Settings
val browseLayout: BrowseLayout = provideDefaultValue { BrowseLayout.LIST },
val browseAutoGridSize: Boolean = provideDefaultValue { true },
@ -374,6 +394,54 @@ data class MainState(
horizontalGesturePullAnim = provideValue(
HORIZONTAL_GESTURE_PULL_ANIM
) { horizontalGesturePullAnim },
libraryLayout = provideValue(
LIBRARY_LAYOUT, convert = { toLibraryLayout() }
) { libraryLayout },
libraryAutoGridSize = provideValue(
LIBRARY_AUTO_GRID_SIZE
) { libraryAutoGridSize },
libraryGridSize = provideValue(
LIBRARY_GRID_SIZE
) { libraryGridSize },
libraryReadButton = provideValue(
LIBRARY_READ_BUTTON
) { libraryReadButton },
libraryShowProgress = provideValue(
LIBRARY_SHOW_PROGRESS
) { libraryShowProgress },
libraryTitlePosition = provideValue(
LIBRARY_TITLE_POSITION, convert = { toLibraryTitlePosition() }
) { libraryTitlePosition },
libraryShowBookCount = provideValue(
LIBRARY_SHOW_BOOK_COUNT
) { libraryShowBookCount },
libraryShowCategoryTabs = provideValue(
LIBRARY_SHOW_CATEGORY_TABS
) { libraryShowCategoryTabs },
libraryAlwaysShowDefaultTab = provideValue(
LIBRARY_ALWAYS_SHOW_DEFAULT_TAB
) { libraryAlwaysShowDefaultTab },
librarySortOrder = provideValue(
LIBRARY_SORT_ORDER, convert = { toLibrarySortOrder() }
) { librarySortOrder },
librarySortOrderDescending = provideValue(
LIBRARY_SORT_ORDER_DESCENDING
) { librarySortOrderDescending },
libraryPerCategorySort = provideValue(
LIBRARY_PER_CATEGORY_SORT
) { libraryPerCategorySort },
)
}
}

View file

@ -0,0 +1,36 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.ui.settings
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.presentation.core.components.top_bar.collapsibleTopAppBarScrollBehavior
import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.presentation.settings.library.LibrarySettingsContent
@Parcelize
object LibrarySettingsScreen : Screen, Parcelable {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
LibrarySettingsContent(
listState = listState,
scrollBehavior = scrollBehavior,
navigateBack = {
navigator.pop()
}
)
}
}

View file

@ -10,6 +10,8 @@ import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.util.ID
@Immutable
@ -22,6 +24,29 @@ sealed class SettingsEvent {
val uri: Uri
) : SettingsEvent()
data class OnCreateCategory(
val title: String
) : SettingsEvent()
data class OnUpdateCategoryTitle(
val id: Int,
val title: String
) : SettingsEvent()
data class OnUpdateCategoryOrder(
val categories: List<Category>
) : SettingsEvent()
data class OnRemoveCategory(
val category: Category
) : SettingsEvent()
data class OnUpdateCategorySort(
val categoryId: Int,
val sortOrder: LibrarySortOrder,
val sortOrderDescending: Boolean
) : SettingsEvent()
data class OnSelectColorPreset(
val id: ID
) : SettingsEvent()

View file

@ -20,7 +20,16 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategorySort
import ua.acclorite.book_story.domain.reader.ColorPreset
import ua.acclorite.book_story.domain.use_case.category.DeleteCategory
import ua.acclorite.book_story.domain.use_case.category.GetCategories
import ua.acclorite.book_story.domain.use_case.category.GetCategorySort
import ua.acclorite.book_story.domain.use_case.category.InsertCategory
import ua.acclorite.book_story.domain.use_case.category.UpdateCategoryOrder
import ua.acclorite.book_story.domain.use_case.category.UpdateCategorySort
import ua.acclorite.book_story.domain.use_case.category.UpdateCategoryTitle
import ua.acclorite.book_story.domain.use_case.color_preset.DeleteColorPreset
import ua.acclorite.book_story.domain.use_case.color_preset.GetColorPresets
import ua.acclorite.book_story.domain.use_case.color_preset.ReorderColorPresets
@ -41,7 +50,14 @@ class SettingsModel @Inject constructor(
private val reorderColorPresets: ReorderColorPresets,
private val deleteColorPreset: DeleteColorPreset,
private val grantPersistableUriPermission: GrantPersistableUriPermission,
private val releasePersistableUriPermission: ReleasePersistableUriPermission
private val releasePersistableUriPermission: ReleasePersistableUriPermission,
private val insertCategory: InsertCategory,
private val getCategories: GetCategories,
private val updateCategoryTitle: UpdateCategoryTitle,
private val updateCategoryOrder: UpdateCategoryOrder,
private val deleteCategory: DeleteCategory,
private val updateCategorySort: UpdateCategorySort,
private val getCategoriesSort: GetCategorySort,
) : ViewModel() {
private val mutex = Mutex()
@ -85,7 +101,9 @@ class SettingsModel @Inject constructor(
_state.update {
it.copy(
selectedColorPreset = colorPresets.selected(),
colorPresets = colorPresets
colorPresets = colorPresets,
categories = getCategories.execute(), // Getting categories
categoriesSort = getCategoriesSort.execute()
)
}
@ -112,6 +130,78 @@ class SettingsModel @Inject constructor(
}
}
is SettingsEvent.OnCreateCategory -> {
viewModelScope.launch {
insertCategory.execute(
category = Category(
title = event.title
)
)
_state.update {
it.copy(
categories = getCategories.execute()
)
}
}
}
is SettingsEvent.OnUpdateCategoryTitle -> {
viewModelScope.launch {
updateCategoryTitle.execute(
id = event.id,
title = event.title
)
_state.update {
it.copy(
categories = getCategories.execute()
)
}
}
}
is SettingsEvent.OnUpdateCategoryOrder -> {
viewModelScope.launch {
updateCategoryOrder.execute(
categories = event.categories
)
_state.update {
it.copy(
categories = getCategories.execute()
)
}
}
}
is SettingsEvent.OnRemoveCategory -> {
viewModelScope.launch {
deleteCategory.execute(
category = event.category
)
_state.update {
it.copy(
categories = getCategories.execute()
)
}
}
}
is SettingsEvent.OnUpdateCategorySort -> {
viewModelScope.launch {
updateCategorySort.execute(
categorySort = CategorySort(
categoryId = event.categoryId,
sortOrder = event.sortOrder,
sortOrderDescending = event.sortOrderDescending
)
)
_state.update {
it.copy(
categoriesSort = getCategoriesSort.execute()
)
}
}
}
is SettingsEvent.OnSelectColorPreset -> {
viewModelScope.launch {
cancelColorPresetJobs()

Some files were not shown because too many files have changed in this diff Show more