merge(feat): 79-custom-categories into develop

This commit is contained in:
Acclorite 2025-04-28 13:25:10 +03:00
commit 291ceb8de8
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
126 changed files with 4521 additions and 722 deletions

View file

@ -136,9 +136,6 @@ dependencies {
// EPUB parser // EPUB parser
implementation("org.jsoup:jsoup:1.18.3") implementation("org.jsoup:jsoup:1.18.3")
// FB2 parser
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1")
// Language Switcher // Language Switcher
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.appcompat:appcompat-resources:1.7.0") implementation("androidx.appcompat:appcompat-resources:1.7.0")
@ -156,9 +153,9 @@ dependencies {
// Scrollbar // Scrollbar
implementation("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") implementation("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0")
// Gson
implementation("com.google.code.gson:gson:2.11.0")
// Markdown // Markdown
implementation("org.commonmark:commonmark:0.24.0") implementation("org.commonmark:commonmark:0.24.0")
// Json
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
} }

View file

@ -0,0 +1,164 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "1bc94970ad93b16bbf70ab7c0b9cf30a",
"entities": [
{
"tableName": "BookEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scrollIndex",
"columnName": "scrollIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scrollOffset",
"columnName": "scrollOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HistoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ColorPresetEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundColor",
"columnName": "backgroundColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fontColor",
"columnName": "fontColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSelected",
"columnName": "isSelected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1bc94970ad93b16bbf70ab7c0b9cf30a')"
]
}
}

View file

@ -0,0 +1,196 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "6e6ecda4455f168e4320d1c7c5d927ed",
"entities": [
{
"tableName": "BookEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scrollIndex",
"columnName": "scrollIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scrollOffset",
"columnName": "scrollOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HistoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ColorPresetEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundColor",
"columnName": "backgroundColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fontColor",
"columnName": "fontColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSelected",
"columnName": "isSelected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CategoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6e6ecda4455f168e4320d1c7c5d927ed')"
]
}
}

View file

@ -0,0 +1,203 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "b48e02ebe9c65bda51e5413bae8308a2",
"entities": [
{
"tableName": "BookEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]')",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scrollIndex",
"columnName": "scrollIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scrollOffset",
"columnName": "scrollOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HistoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ColorPresetEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundColor",
"columnName": "backgroundColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fontColor",
"columnName": "fontColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSelected",
"columnName": "isSelected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CategoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b48e02ebe9c65bda51e5413bae8308a2')"
]
}
}

View file

@ -0,0 +1,235 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "7b5d7f69a5a7d3b9878a72578ce34edc",
"entities": [
{
"tableName": "BookEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]')",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scrollIndex",
"columnName": "scrollIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scrollOffset",
"columnName": "scrollOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HistoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ColorPresetEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundColor",
"columnName": "backgroundColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fontColor",
"columnName": "fontColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSelected",
"columnName": "isSelected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CategoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CategorySortEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`categoryId` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL, `sortOrderDescending` INTEGER NOT NULL, PRIMARY KEY(`categoryId`))",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrderDescending",
"columnName": "sortOrderDescending",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"categoryId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b5d7f69a5a7d3b9878a72578ce34edc')"
]
}
}

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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideCommonmarkParser(): Parser { 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.local.data_store.DataStoreImpl
import ua.acclorite.book_story.data.mapper.book.BookMapper 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.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.ColorPresetMapper
import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapperImpl import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapperImpl
import ua.acclorite.book_story.data.mapper.history.HistoryMapper 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.TextParser
import ua.acclorite.book_story.data.parser.TextParserImpl import ua.acclorite.book_story.data.parser.TextParserImpl
import ua.acclorite.book_story.data.repository.BookRepositoryImpl 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.ColorPresetRepositoryImpl
import ua.acclorite.book_story.data.repository.DataStoreRepositoryImpl import ua.acclorite.book_story.data.repository.DataStoreRepositoryImpl
import ua.acclorite.book_story.data.repository.FileSystemRepositoryImpl import ua.acclorite.book_story.data.repository.FileSystemRepositoryImpl
import ua.acclorite.book_story.data.repository.HistoryRepositoryImpl import ua.acclorite.book_story.data.repository.HistoryRepositoryImpl
import ua.acclorite.book_story.data.repository.PermissionRepositoryImpl import ua.acclorite.book_story.data.repository.PermissionRepositoryImpl
import ua.acclorite.book_story.domain.repository.BookRepository 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.ColorPresetRepository
import ua.acclorite.book_story.domain.repository.DataStoreRepository import ua.acclorite.book_story.domain.repository.DataStoreRepository
import ua.acclorite.book_story.domain.repository.FileSystemRepository import ua.acclorite.book_story.domain.repository.FileSystemRepository
@ -81,6 +87,12 @@ abstract class RepositoryModule {
permissionRepositoryImpl: PermissionRepositoryImpl permissionRepositoryImpl: PermissionRepositoryImpl
): PermissionRepository ): PermissionRepository
@Binds
@Singleton
abstract fun bindCategoryRepository(
categoryRepositoryImpl: CategoryRepositoryImpl
): CategoryRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindBookMapper( abstract fun bindBookMapper(
@ -99,6 +111,18 @@ abstract class RepositoryModule {
colorPresetMapperImpl: ColorPresetMapperImpl colorPresetMapperImpl: ColorPresetMapperImpl
): ColorPresetMapper ): ColorPresetMapper
@Binds
@Singleton
abstract fun bindCategoryMapper(
categoryMapperImpl: CategoryMapperImpl
): CategoryMapper
@Binds
@Singleton
abstract fun bindCategorySortMapper(
categorySortSortMapperImpl: CategorySortSortMapperImpl
): CategorySortMapper
@Binds @Binds
@Singleton @Singleton
abstract fun bindFileParser( abstract fun bindFileParser(

View file

@ -6,11 +6,14 @@
package ua.acclorite.book_story.data.local.dto package ua.acclorite.book_story.data.local.dto
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey 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 @Entity
@TypeConverters(CategoryConverter::class)
data class BookEntity( data class BookEntity(
@PrimaryKey(true) val id: Int = 0, @PrimaryKey(true) val id: Int = 0,
val title: String, val title: String,
@ -21,5 +24,5 @@ data class BookEntity(
val scrollOffset: Int, val scrollOffset: Int,
val progress: Float, val progress: Float,
val image: String? = null, 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.Update
import androidx.room.Upsert import androidx.room.Upsert
import ua.acclorite.book_story.data.local.dto.BookEntity 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.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
@ -93,4 +95,47 @@ interface BookDao {
@Query("DELETE FROM colorpresetentity") @Query("DELETE FROM colorpresetentity")
suspend fun deleteColorPresets() 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.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity 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.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
import java.io.File import java.io.File
@ -25,8 +27,10 @@ import java.io.File
BookEntity::class, BookEntity::class,
HistoryEntity::class, HistoryEntity::class,
ColorPresetEntity::class, ColorPresetEntity::class,
CategoryEntity::class,
CategorySortEntity::class
], ],
version = 9, version = 13,
autoMigrations = [ autoMigrations = [
AutoMigration(1, 2), AutoMigration(1, 2),
AutoMigration(2, 3), AutoMigration(2, 3),
@ -36,6 +40,10 @@ import java.io.File
AutoMigration(6, 7), AutoMigration(6, 7),
AutoMigration(7, 8, spec = DatabaseHelper.MIGRATION_7_8::class), AutoMigration(7, 8, spec = DatabaseHelper.MIGRATION_7_8::class),
AutoMigration(8, 9, spec = DatabaseHelper.MIGRATION_8_9::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 exportSchema = true
) )
@ -113,4 +121,7 @@ object DatabaseHelper {
@DeleteTable("FavoriteDirectoryEntity") @DeleteTable("FavoriteDirectoryEntity")
class MIGRATION_8_9 : AutoMigrationSpec 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(), author = book.author.getAsString(),
description = book.description, description = book.description,
image = book.coverImage?.toString(), image = book.coverImage?.toString(),
category = book.category categories = book.categories
) )
} }
@ -42,8 +42,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
progress = bookEntity.progress, progress = bookEntity.progress,
filePath = bookEntity.filePath, filePath = bookEntity.filePath,
lastOpened = null, 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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book 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.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
import java.io.File import java.io.File
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -91,7 +90,7 @@ class EpubFileParser @Inject constructor() : FileParser {
progress = 0f, progress = 0f,
filePath = cachedFile.path, filePath = cachedFile.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], categories = emptyList(),
coverImage = null coverImage = null
), ),
coverImage = extractCoverImageBitmap(rawFile, coverImage) 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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book 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.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject import javax.inject.Inject
@ -56,7 +55,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
progress = 0f, progress = 0f,
filePath = cachedFile.path, filePath = cachedFile.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], categories = emptyList(),
coverImage = null coverImage = null
), ),
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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book 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.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject import javax.inject.Inject
@ -42,7 +41,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
progress = 0f, progress = 0f,
filePath = cachedFile.path, filePath = cachedFile.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], categories = emptyList(),
coverImage = null coverImage = null
), ),
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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book 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.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject import javax.inject.Inject
@ -47,7 +46,7 @@ class PdfFileParser @Inject constructor(
progress = 0f, progress = 0f,
filePath = cachedFile.path, filePath = cachedFile.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], categories = emptyList(),
coverImage = null coverImage = null
), ),
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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book 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.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
import javax.inject.Inject import javax.inject.Inject
@ -32,7 +31,7 @@ class TxtFileParser @Inject constructor() : FileParser {
progress = 0f, progress = 0f,
filePath = cachedFile.path, filePath = cachedFile.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], categories = emptyList(),
coverImage = null coverImage = null
), ),
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 android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
@Parcelize @Parcelize
@ -30,5 +29,5 @@ data class Book(
val progress: Float, val progress: Float,
val lastOpened: Long?, val lastOpened: Long?,
val category: Category val categories: List<Int>
) : Parcelable ) : Parcelable

View file

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

View file

@ -7,12 +7,11 @@
package ua.acclorite.book_story.domain.library.category package ua.acclorite.book_story.domain.library.category
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.library.book.SelectableBook import ua.acclorite.book_story.domain.library.display.LibrarySortOrder
import ua.acclorite.book_story.domain.ui.UIText
@Immutable @Immutable
data class CategoryWithBooks( data class CategorySort(
val category: Category, val categoryId: Int,
val title: UIText, val sortOrder: LibrarySortOrder,
val books: List<SelectableBook> 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.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.book.Book 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.BottomSheet
import ua.acclorite.book_story.domain.util.Dialog 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.BookInfoEvent
@ -16,6 +17,7 @@ import ua.acclorite.book_story.ui.book_info.BookInfoEvent
@Composable @Composable
fun BookInfoContent( fun BookInfoContent(
book: Book, book: Book,
categories: List<Category>,
bottomSheet: BottomSheet?, bottomSheet: BottomSheet?,
dialog: Dialog?, dialog: Dialog?,
listState: LazyListState, listState: LazyListState,
@ -41,12 +43,13 @@ fun BookInfoContent(
dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit, dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit,
dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit, dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit,
navigateToReader: () -> Unit, navigateToReader: () -> Unit,
navigateToLibrary: () -> Unit, navigateToLibrarySettings: () -> Unit,
navigateBack: () -> Unit navigateBack: () -> Unit
) { ) {
BookInfoDialog( BookInfoDialog(
dialog = dialog, dialog = dialog,
book = book, book = book,
categories = categories,
actionTitleDialog = actionTitleDialog, actionTitleDialog = actionTitleDialog,
actionAuthorDialog = actionAuthorDialog, actionAuthorDialog = actionAuthorDialog,
actionDescriptionDialog = actionDescriptionDialog, actionDescriptionDialog = actionDescriptionDialog,
@ -54,8 +57,8 @@ fun BookInfoContent(
actionDeleteDialog = actionDeleteDialog, actionDeleteDialog = actionDeleteDialog,
actionMoveDialog = actionMoveDialog, actionMoveDialog = actionMoveDialog,
dismissDialog = dismissDialog, dismissDialog = dismissDialog,
navigateToLibrary = navigateToLibrary, navigateBack = navigateBack,
navigateBack = navigateBack navigateToLibrarySettings = navigateToLibrarySettings
) )
BookInfoBottomSheet( BookInfoBottomSheet(

View file

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

View file

@ -6,69 +6,93 @@
package ua.acclorite.book_story.presentation.book_info package ua.acclorite.book_story.presentation.book_info
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material3.MaterialTheme
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.runtime.Composable 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.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.Book import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.category.Category 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.Dialog
import ua.acclorite.book_story.presentation.core.components.dialog.SelectableDialogItem
import ua.acclorite.book_story.ui.book_info.BookInfoEvent import ua.acclorite.book_story.ui.book_info.BookInfoEvent
@Composable @Composable
fun BookInfoMoveDialog( fun BookInfoMoveDialog(
book: Book, book: Book,
categories: List<Category>,
actionMoveDialog: (BookInfoEvent.OnActionMoveDialog) -> Unit, actionMoveDialog: (BookInfoEvent.OnActionMoveDialog) -> Unit,
dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit, dismissDialog: (BookInfoEvent.OnDismissDialog) -> Unit,
navigateToLibrary: () -> Unit navigateToLibrarySettings: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val selectedCategories = remember {
val categories = remember { mutableStateListOf<Category>().apply {
Category.entries.filter { book.category != it } clear()
categories.forEach { category ->
if (book.categories.any { it == category.id }) {
add(category)
}
}
} }
val selectedCategory = remember {
mutableStateOf(categories[0])
} }
Dialog( Dialog(
title = stringResource(id = R.string.move_book), title = stringResource(id = R.string.move),
icon = Icons.AutoMirrored.Outlined.DriveFileMove, description = null,
description = stringResource(
id = R.string.move_book_description
),
actionEnabled = true, actionEnabled = true,
onDismiss = { dismissDialog(BookInfoEvent.OnDismissDialog) }, onDismiss = { dismissDialog(BookInfoEvent.OnDismissDialog) },
onAction = { onAction = {
actionMoveDialog( actionMoveDialog(
BookInfoEvent.OnActionMoveDialog( BookInfoEvent.OnActionMoveDialog(
category = selectedCategory.value, selectedCategories = selectedCategories,
context = context, context = context
navigateToLibrary = navigateToLibrary
) )
) )
}, },
secondaryAction = stringResource(id = R.string.edit),
onSecondaryAction = {
navigateToLibrarySettings()
dismissDialog(BookInfoEvent.OnDismissDialog)
},
withContent = true, withContent = true,
items = { items = {
items(categories, key = { it.name }) { items(categories) { category ->
val category = when (it) { val selected = remember(
Category.READING -> stringResource(id = R.string.reading_tab) category,
Category.ALREADY_READ -> stringResource(id = R.string.already_read_tab) selectedCategories
Category.PLANNING -> stringResource(id = R.string.planning_tab) ) {
Category.DROPPED -> stringResource(id = R.string.dropped_tab) derivedStateOf {
selectedCategories.any { it == category }
}
} }
SelectableDialogItem( CheckboxDialogItem(
selected = it == selectedCategory.value, selected = selected.value,
title = category 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) { when (page) {
0 -> { 0 -> {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) { LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
BrowseFilterSubcategory( BrowseSortSubcategory(
showTitle = false, showTitle = false,
showDivider = false showDivider = false
) )
@ -66,7 +66,7 @@ fun BrowseFilterBottomSheet(
1 -> { 1 -> {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) { LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
BrowseSortSubcategory( BrowseFilterSubcategory(
showTitle = false, showTitle = false,
showDivider = false showDivider = false
) )

View file

@ -17,8 +17,8 @@ fun BrowseFilterBottomSheetTabRow(
scrollToPage: (Int) -> Unit scrollToPage: (Int) -> Unit
) { ) {
val tabItems = listOf( val tabItems = listOf(
stringResource(id = R.string.filter_tab),
stringResource(id = R.string.sort_tab), stringResource(id = R.string.sort_tab),
stringResource(id = R.string.filter_tab),
stringResource(id = R.string.display_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 androidx.compose.ui.graphics.Color
import ua.acclorite.book_story.domain.library.book.Book 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.reader.ColorPreset
import ua.acclorite.book_story.domain.ui.UIText import ua.acclorite.book_story.domain.ui.UIText
@ -29,7 +28,7 @@ fun provideEmptyBook() = Book(
scrollOffset = 0, scrollOffset = 0,
progress = 0f, progress = 0f,
lastOpened = null, lastOpened = null,
category = Category.READING categories = emptyList()
) )
// Default Color Preset // Default Color Preset

View file

@ -67,6 +67,20 @@ object DataStoreConstants {
val PROGRESS_BAR_FONT_SIZE = intPreferencesKey("progress_bar_font_size") val PROGRESS_BAR_FONT_SIZE = intPreferencesKey("progress_bar_font_size")
val PROGRESS_COUNT = stringPreferencesKey("progress_count") 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 // Browse settings
val BROWSE_LAYOUT = stringPreferencesKey("browse_layout") val BROWSE_LAYOUT = stringPreferencesKey("browse_layout")
val BROWSE_AUTO_GRID_SIZE = booleanPreferencesKey("browse_auto_grid_size") val BROWSE_AUTO_GRID_SIZE = booleanPreferencesKey("browse_auto_grid_size")

View file

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

View file

@ -8,7 +8,7 @@ package ua.acclorite.book_story.presentation.library
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.library.book.SelectableBook 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.domain.util.Dialog
import ua.acclorite.book_story.ui.library.LibraryEvent import ua.acclorite.book_story.ui.library.LibraryEvent
import ua.acclorite.book_story.ui.library.LibraryScreen import ua.acclorite.book_story.ui.library.LibraryScreen
@ -17,20 +17,21 @@ import ua.acclorite.book_story.ui.library.LibraryScreen
fun LibraryDialog( fun LibraryDialog(
dialog: Dialog?, dialog: Dialog?,
books: List<SelectableBook>, books: List<SelectableBook>,
categories: List<CategoryWithBooks>, categories: List<Category>,
selectedItemsCount: Int, selectedItemsCount: Int,
actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit, actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit,
actionDeleteDialog: (LibraryEvent.OnActionDeleteDialog) -> Unit, actionDeleteDialog: (LibraryEvent.OnActionDeleteDialog) -> Unit,
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit,
navigateToLibrarySettings: () -> Unit
) { ) {
when (dialog) { when (dialog) {
LibraryScreen.MOVE_DIALOG -> { LibraryScreen.MOVE_DIALOG -> {
LibraryMoveDialog( LibraryMoveDialog(
books = books, books = books,
categories = categories, categories = categories,
selectedItemsCount = selectedItemsCount,
actionMoveDialog = actionMoveDialog, 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 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.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.domain.library.book.SelectableBook
import ua.acclorite.book_story.presentation.core.components.common.AsyncCoverImage import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.presentation.core.components.common.StyledText import ua.acclorite.book_story.domain.library.display.LibraryTitlePosition
import ua.acclorite.book_story.presentation.core.util.calculateProgress
@Composable @Composable
fun LazyGridItemScope.LibraryItem( fun LibraryItem(
book: SelectableBook, book: SelectableBook,
layout: LibraryLayout,
hasSelectedItems: Boolean, hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
selectBook: (select: Boolean?) -> Unit, selectBook: (select: Boolean?) -> Unit,
navigateToBookInfo: () -> Unit, navigateToBookInfo: () -> Unit,
navigateToReader: () -> Unit navigateToReader: () -> Unit
) { ) {
val backgroundColor = if (book.selected) MaterialTheme.colorScheme.secondary when (layout) {
else Color.Transparent LibraryLayout.LIST -> {
val fontColor = if (book.selected) MaterialTheme.colorScheme.onSecondary LibraryListItem(
else MaterialTheme.colorScheme.onSurface book = book,
hasSelectedItems = hasSelectedItems,
val progress = rememberSaveable(book.data.progress) { readButton = readButton,
"${book.data.progress.calculateProgress(1)}%" showProgress = showProgress,
} selectBook = selectBook,
navigateToBookInfo = navigateToBookInfo,
Column( navigateToReader = navigateToReader
Modifier
.animateItem()
.padding(3.dp)
.background(
backgroundColor,
MaterialTheme.shapes.large
)
.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( LibraryLayout.GRID -> {
text = progress, LibraryGridItem(
modifier = Modifier book = book,
.padding(6.dp) hasSelectedItems = hasSelectedItems,
.clip(MaterialTheme.shapes.small) titlePosition = titlePosition,
.background(MaterialTheme.colorScheme.secondary, MaterialTheme.shapes.small) readButton = readButton,
.padding(horizontal = 8.dp, vertical = 4.dp), showProgress = showProgress,
style = MaterialTheme.typography.bodySmall.copy( selectBook = selectBook,
textAlign = TextAlign.Center, navigateToBookInfo = navigateToBookInfo,
color = MaterialTheme.colorScheme.onSecondary, navigateToReader = navigateToReader
)
)
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 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.runtime.Composable
import androidx.compose.ui.Modifier import ua.acclorite.book_story.domain.library.book.SelectableBook
import androidx.compose.ui.unit.dp import ua.acclorite.book_story.domain.library.display.LibraryLayout
import ua.acclorite.book_story.presentation.core.components.common.LazyVerticalGridWithScrollbar
import ua.acclorite.book_story.presentation.core.constants.providePrimaryScrollbar
@Composable @Composable
fun LibraryLayout( fun LibraryLayout(
items: LazyGridScope.() -> Unit books: List<SelectableBook>,
layout: LibraryLayout,
gridSize: Int,
autoGridSize: Boolean,
itemContent: @Composable (book: SelectableBook) -> Unit
) { ) {
LazyVerticalGridWithScrollbar( when (layout) {
columns = GridCells.Adaptive(120.dp), LibraryLayout.LIST -> {
modifier = Modifier.fillMaxSize(), LibraryListLayout(
scrollbarSettings = providePrimaryScrollbar(false), books = books,
contentPadding = PaddingValues(8.dp) itemContent = itemContent
) { )
items() }
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 package ua.acclorite.book_story.presentation.library
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material3.MaterialTheme
import androidx.compose.material.icons.outlined.MoveUp
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.SelectableBook 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.Dialog
import ua.acclorite.book_story.presentation.core.components.dialog.SelectableDialogItem
import ua.acclorite.book_story.ui.library.LibraryEvent import ua.acclorite.book_story.ui.library.LibraryEvent
@Composable @Composable
fun LibraryMoveDialog( fun LibraryMoveDialog(
books: List<SelectableBook>, books: List<SelectableBook>,
categories: List<CategoryWithBooks>, categories: List<Category>,
selectedItemsCount: Int,
actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit, actionMoveDialog: (LibraryEvent.OnActionMoveDialog) -> Unit,
dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit dismissDialog: (LibraryEvent.OnDismissDialog) -> Unit,
navigateToLibrarySettings: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val selectedBooks = remember { val selectedBooks = remember {
derivedStateOf { derivedStateOf {
books.filter { it.selected } books.filter { it.selected }
} }
} }
val moveCategories = remember { val selectedCategories = remember {
derivedStateOf { mutableStateListOf<Category>().apply {
categories.mapNotNull { category -> clear()
if (!selectedBooks.value.all { it.data.category == category.category }) { categories.forEach { category ->
return@mapNotNull 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( Dialog(
title = stringResource(id = R.string.move_books), title = stringResource(id = R.string.move),
icon = Icons.Outlined.MoveUp, description = null,
description = stringResource(
id = R.string.move_books_description,
selectedItemsCount
),
actionEnabled = true, actionEnabled = true,
onDismiss = { onDismiss = {
dismissDialog(LibraryEvent.OnDismissDialog) dismissDialog(LibraryEvent.OnDismissDialog)
@ -64,20 +61,46 @@ fun LibraryMoveDialog(
onAction = { onAction = {
actionMoveDialog( actionMoveDialog(
LibraryEvent.OnActionMoveDialog( LibraryEvent.OnActionMoveDialog(
selectedCategory = selectedCategory.value.category, selectedCategories = selectedCategories,
categories = categories,
context = context context = context
) )
) )
}, },
secondaryAction = stringResource(id = R.string.edit),
onSecondaryAction = {
navigateToLibrarySettings()
dismissDialog(LibraryEvent.OnDismissDialog)
},
withContent = true, withContent = true,
items = { items = {
items(moveCategories.value) { category -> items(categories) { category ->
SelectableDialogItem( val selected = remember(
selected = category == selectedCategory.value, category,
title = category.title.asString() 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.library.LibraryEvent
import ua.acclorite.book_story.ui.theme.DefaultTransition import ua.acclorite.book_story.ui.theme.DefaultTransition
@Composable @Composable
fun LibraryPager( fun LibraryPager(
books: List<SelectableBook>,
pagerState: PagerState, 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, hasSelectedItems: Boolean,
titlePosition: LibraryTitlePosition,
readButton: Boolean,
showProgress: Boolean,
isLoading: Boolean, isLoading: Boolean,
isRefreshing: Boolean, isRefreshing: Boolean,
selectBook: (LibraryEvent.OnSelectBook) -> Unit, selectBook: (LibraryEvent.OnSelectBook) -> Unit,
@ -31,23 +48,122 @@ fun LibraryPager(
navigateToBookInfo: (id: Int) -> Unit, navigateToBookInfo: (id: Int) -> Unit,
navigateToReader: (id: Int) -> Unit, navigateToReader: (id: Int) -> Unit,
) { ) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { index -> val categorizedBooks = remember(
val category = remember(categories, index) { 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 { derivedStateOf {
categories[index] 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(categorizedBooks, index) {
derivedStateOf {
categorizedBooks.value[index]
} }
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
DefaultTransition(visible = !isLoading) { DefaultTransition(visible = !isLoading) {
LibraryLayout { LibraryLayout(
items( books = category.value,
category.value.books, gridSize = gridSize,
key = { it.data.id } autoGridSize = autoGridSize,
layout = layout
) { book -> ) { book ->
LibraryItem( LibraryItem(
book = book, book = book,
layout = layout,
hasSelectedItems = hasSelectedItems, hasSelectedItems = hasSelectedItems,
titlePosition = titlePosition,
readButton = readButton,
showProgress = showProgress,
selectBook = { select -> selectBook = { select ->
selectBook( selectBook(
LibraryEvent.OnSelectBook( LibraryEvent.OnSelectBook(
@ -61,12 +177,11 @@ fun LibraryPager(
) )
} }
} }
}
LibraryEmptyPlaceholder( LibraryEmptyPlaceholder(
isLoading = isLoading, isLoading = isLoading,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
isBooksEmpty = category.value.books.isEmpty(), isBooksEmpty = category.value.isEmpty(),
navigateToBrowse = navigateToBrowse navigateToBrowse = navigateToBrowse
) )
} }

View file

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

View file

@ -29,14 +29,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch 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 import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable @Composable
fun LibraryTabs( fun LibraryTabs(
categories: List<CategoryWithBooks>, categoriesWithBookCount: List<Pair<Category, Int>>,
pagerState: PagerState, pagerState: PagerState,
itemCountBackgroundColor: Color, itemCountBackgroundColor: Color,
showBookCount: Boolean
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -69,7 +70,7 @@ fun LibraryTabs(
} }
} }
) { ) {
categories.forEachIndexed { index, tabItem -> categoriesWithBookCount.forEachIndexed { index, tabItem ->
Tab( Tab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { onClick = {
@ -82,13 +83,15 @@ fun LibraryTabs(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
StyledText( StyledText(
text = tabItem.title.asString(), text = tabItem.first.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
) )
if (showBookCount) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
StyledText( StyledText(
text = tabItem.books.count().toString(), text = tabItem.second.toString(),
modifier = Modifier modifier = Modifier
.background( .background(
itemCountBackgroundColor, itemCountBackgroundColor,
@ -101,6 +104,7 @@ fun LibraryTabs(
) )
} }
} }
}
) )
} }
} }

View file

@ -9,6 +9,8 @@ package ua.acclorite.book_story.presentation.library
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear 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.filled.Search
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.MoveUp 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.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.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.IconButton
import ua.acclorite.book_story.presentation.core.components.common.SearchTextField import ua.acclorite.book_story.presentation.core.components.common.SearchTextField
import ua.acclorite.book_story.presentation.core.components.common.StyledText 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LibraryTopBar( fun LibraryTopBar(
books: List<SelectableBook>,
selectedItemsCount: Int, selectedItemsCount: Int,
hasSelectedItems: Boolean, hasSelectedItems: Boolean,
showBookCount: Boolean,
showCategoryTabs: Boolean,
showSearch: Boolean, showSearch: Boolean,
searchQuery: String, searchQuery: String,
bookCount: Int, bookCount: Int,
@ -56,15 +66,49 @@ fun LibraryTopBar(
pagerState: PagerState, pagerState: PagerState,
isLoading: Boolean, isLoading: Boolean,
isRefreshing: Boolean, isRefreshing: Boolean,
categories: List<CategoryWithBooks>, categories: List<Category>,
showDefaultCategory: Boolean,
searchVisibility: (LibraryEvent.OnSearchVisibility) -> Unit, searchVisibility: (LibraryEvent.OnSearchVisibility) -> Unit,
requestFocus: (LibraryEvent.OnRequestFocus) -> Unit, requestFocus: (LibraryEvent.OnRequestFocus) -> Unit,
searchQueryChange: (LibraryEvent.OnSearchQueryChange) -> Unit, searchQueryChange: (LibraryEvent.OnSearchQueryChange) -> Unit,
search: (LibraryEvent.OnSearch) -> Unit, search: (LibraryEvent.OnSearch) -> Unit,
clearSelectedBooks: (LibraryEvent.OnClearSelectedBooks) -> Unit, clearSelectedBooks: (LibraryEvent.OnClearSelectedBooks) -> Unit,
showMoveDialog: (LibraryEvent.OnShowMoveDialog) -> 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( val animatedItemCountBackgroundColor = animateColorAsState(
if (hasSelectedItems) MaterialTheme.colorScheme.surfaceContainerHighest if (hasSelectedItems) MaterialTheme.colorScheme.surfaceContainerHighest
else MaterialTheme.colorScheme.surfaceContainer, else MaterialTheme.colorScheme.surfaceContainer,
@ -86,10 +130,16 @@ fun LibraryTopBar(
contentNavigationIcon = {}, contentNavigationIcon = {},
contentTitle = { contentTitle = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
StyledText(text = stringResource(id = R.string.library_screen)) StyledText(
text = if (showCategoryTabs) stringResource(id = R.string.library_screen)
else currentCategory.value.first.title
)
if (showBookCount) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
StyledText( StyledText(
text = bookCount.toString(), text = if (showCategoryTabs) bookCount.toString()
else currentCategory.value.second.toString(),
modifier = Modifier modifier = Modifier
.background( .background(
MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainer,
@ -102,6 +152,7 @@ fun LibraryTopBar(
) )
) )
} }
}
}, },
contentActions = { contentActions = {
IconButton( IconButton(
@ -111,6 +162,13 @@ fun LibraryTopBar(
) { ) {
searchVisibility(LibraryEvent.OnSearchVisibility(true)) searchVisibility(LibraryEvent.OnSearchVisibility(true))
} }
IconButton(
icon = Icons.Default.FilterList,
contentDescription = R.string.filter_content_desc,
disableOnClick = false,
) {
showFilterBottomSheet(LibraryEvent.OnShowFilterBottomSheet)
}
NavigatorIconButton() NavigatorIconButton()
} }
), ),
@ -188,11 +246,18 @@ fun LibraryTopBar(
), ),
), ),
customContent = { customContent = {
AnimatedVisibility(
visible = showCategoryTabs,
enter = expandVertically(),
exit = shrinkVertically()
) {
LibraryTabs( LibraryTabs(
categories = categories, categoriesWithBookCount = categoriesWithBookCount.value,
pagerState = pagerState, pagerState = pagerState,
itemCountBackgroundColor = animatedItemCountBackgroundColor.value itemCountBackgroundColor = animatedItemCountBackgroundColor.value,
showBookCount = showBookCount
) )
} }
}
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import ua.acclorite.book_story.presentation.settings.components.SettingsSubcateg
fun LazyListScope.BrowseSortSubcategory( fun LazyListScope.BrowseSortSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary }, 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, showTitle: Boolean = true,
showDivider: Boolean = true showDivider: Boolean = true
) { ) {

View file

@ -90,10 +90,10 @@ private fun BrowseSortOptionItem(
StyledText( StyledText(
text = stringResource( text = stringResource(
when (item) { when (item) {
BrowseSortOrder.NAME -> R.string.browse_sort_order_name BrowseSortOrder.NAME -> R.string.sort_order_name
BrowseSortOrder.FILE_FORMAT -> R.string.browse_sort_order_file_format BrowseSortOrder.FILE_FORMAT -> R.string.sort_order_file_format
BrowseSortOrder.LAST_MODIFIED -> R.string.browse_sort_order_last_modified BrowseSortOrder.LAST_MODIFIED -> R.string.sort_order_last_modified
BrowseSortOrder.FILE_SIZE -> R.string.browse_sort_order_file_size BrowseSortOrder.FILE_SIZE -> R.string.sort_order_file_size
} }
), ),
style = MaterialTheme.typography.bodyLarge, 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 object OnShowMoveDialog : BookInfoEvent()
data class OnActionMoveDialog( data class OnActionMoveDialog(
val category: Category, val selectedCategories: List<Category>,
val context: Context, val context: Context
val navigateToLibrary: () -> Unit
) : BookInfoEvent() ) : BookInfoEvent()
data object OnDismissDialog : BookInfoEvent() data object OnDismissDialog : BookInfoEvent()

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import ua.acclorite.book_story.R 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.CanResetCover
import ua.acclorite.book_story.domain.use_case.book.DeleteBooks import ua.acclorite.book_story.domain.use_case.book.DeleteBooks
import ua.acclorite.book_story.domain.use_case.book.GetBookById import ua.acclorite.book_story.domain.use_case.book.GetBookById
@ -357,34 +356,22 @@ class BookInfoModel @Inject constructor(
launch { launch {
_state.update { _state.update {
it.copy( it.copy(
book = it.book.copy(
categories = event.selectedCategories.map { it.id }
),
dialog = null, dialog = null,
bottomSheet = null bottomSheet = null
) )
} }
_state.update {
it.copy(
book = it.book.copy(
category = event.category
)
)
}
updateBook.execute(_state.value.book) updateBook.execute(_state.value.book)
LibraryScreen.refreshListChannel.trySend(0) LibraryScreen.refreshListChannel.trySend(0)
LibraryScreen.scrollToPageCompositionChannel.trySend(
Category.entries.dropLastWhile {
it != event.category
}.size - 1
)
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
event.context.getString(R.string.book_moved) event.context.getString(R.string.book_moved)
.showToast(context = event.context) .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.book_info.BookInfoContent
import ua.acclorite.book_story.presentation.navigator.LocalNavigator import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.ui.history.HistoryScreen 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.reader.ReaderScreen
import ua.acclorite.book_story.ui.settings.LibrarySettingsScreen
import ua.acclorite.book_story.ui.settings.SettingsModel
@Parcelize @Parcelize
data class BookInfoScreen(val bookId: Int) : Screen, Parcelable { data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
@ -47,7 +48,9 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
override fun Content() { override fun Content() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val screenModel = hiltViewModel<BookInfoModel>() val screenModel = hiltViewModel<BookInfoModel>()
val settingsModel = hiltViewModel<SettingsModel>()
val settingsState = settingsModel.state.collectAsStateWithLifecycle()
val state = screenModel.state.collectAsStateWithLifecycle() val state = screenModel.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -72,6 +75,7 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
if (state.value.book.id == bookId) { if (state.value.book.id == bookId) {
BookInfoContent( BookInfoContent(
book = state.value.book, book = state.value.book,
categories = settingsState.value.categories,
bottomSheet = state.value.bottomSheet, bottomSheet = state.value.bottomSheet,
dialog = state.value.dialog, dialog = state.value.dialog,
listState = listState, listState = listState,
@ -102,12 +106,8 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
navigator.push(ReaderScreen(state.value.book.id)) navigator.push(ReaderScreen(state.value.book.id))
} }
}, },
navigateToLibrary = { navigateToLibrarySettings = {
navigator.push( navigator.push(LibrarySettingsScreen)
LibraryScreen,
popping = true,
saveInBackStack = false
)
}, },
navigateBack = { navigateBack = {
navigator.pop() 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.book.InsertBook
import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFile 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.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.presentation.core.util.showToast
import ua.acclorite.book_story.ui.library.LibraryScreen import ua.acclorite.book_story.ui.library.LibraryScreen
import javax.inject.Inject import javax.inject.Inject
@ -419,16 +420,6 @@ class BrowseModel @Inject constructor(
includedFilterItems: List<String>, includedFilterItems: List<String>,
sortOrder: BrowseSortOrder sortOrder: BrowseSortOrder
): List<SelectableFile> { ): List<SelectableFile> {
fun <T> compareByWithOrder(
selector: (T) -> Comparable<*>?
): Comparator<T> {
return if (sortOrderDescending) {
compareByDescending(selector)
} else {
compareBy(selector)
}
}
fun List<SelectableFile>.filterFiles(): List<SelectableFile> { fun List<SelectableFile>.filterFiles(): List<SelectableFile> {
if (includedFilterItems.isEmpty()) { if (includedFilterItems.isEmpty()) {
return this return this
@ -446,7 +437,7 @@ class BrowseModel @Inject constructor(
return files return files
.filterFiles() .filterFiles()
.sortedWith( .sortedWith(
compareByWithOrder { compareByWithOrder(sortOrderDescending) {
when (sortOrder) { when (sortOrder) {
BrowseSortOrder.NAME -> { BrowseSortOrder.NAME -> {
it.data.name.trim() it.data.name.trim()

View file

@ -10,7 +10,6 @@ import android.content.Context
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.library.category.Category import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.library.category.CategoryWithBooks
@Immutable @Immutable
sealed class LibraryEvent { sealed class LibraryEvent {
@ -43,8 +42,7 @@ sealed class LibraryEvent {
data object OnShowMoveDialog : LibraryEvent() data object OnShowMoveDialog : LibraryEvent()
data class OnActionMoveDialog( data class OnActionMoveDialog(
val selectedCategory: Category, val selectedCategories: List<Category>,
val categories: List<CategoryWithBooks>,
val context: Context val context: Context
) : LibraryEvent() ) : LibraryEvent()
@ -55,4 +53,8 @@ sealed class LibraryEvent {
) : LibraryEvent() ) : LibraryEvent()
data object OnDismissDialog : 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 if (!book.selected) return@forEach
moveBooks.execute( moveBooks.execute(
book.data.copy( 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 if (!book.selected) return@map book
book.copy( book.copy(
data = book.data.copy( data = book.data.copy(
category = event.selectedCategory categories = event.selectedCategories.map { it.id }
), ),
selected = false selected = false
) )
@ -232,11 +232,6 @@ class LibraryModel @Inject constructor(
} }
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)
LibraryScreen.scrollToPageCompositionChannel.trySend(
event.categories.dropLastWhile {
it.category != event.selectedCategory
}.lastIndex
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
event.context 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.coroutines.flow.receiveAsFlow
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize 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.navigator.Screen
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.presentation.library.LibraryContent import ua.acclorite.book_story.presentation.library.LibraryContent
import ua.acclorite.book_story.presentation.navigator.LocalNavigator import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.ui.book_info.BookInfoScreen 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.history.HistoryScreen
import ua.acclorite.book_story.ui.main.MainModel import ua.acclorite.book_story.ui.main.MainModel
import ua.acclorite.book_story.ui.reader.ReaderScreen 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 @Parcelize
object LibraryScreen : Screen, Parcelable { object LibraryScreen : Screen, Parcelable {
@ -45,6 +43,9 @@ object LibraryScreen : Screen, Parcelable {
@IgnoredOnParcel @IgnoredOnParcel
const val DELETE_DIALOG = "delete_dialog" const val DELETE_DIALOG = "delete_dialog"
@IgnoredOnParcel
const val FILTER_BOTTOM_SHEET = "filter_bottom_sheet"
@IgnoredOnParcel @IgnoredOnParcel
val refreshListChannel: Channel<Long> = Channel(Channel.CONFLATED) val refreshListChannel: Channel<Long> = Channel(Channel.CONFLATED)
@ -61,34 +62,19 @@ object LibraryScreen : Screen, Parcelable {
val screenModel = hiltViewModel<LibraryModel>() val screenModel = hiltViewModel<LibraryModel>()
val mainModel = hiltViewModel<MainModel>() val mainModel = hiltViewModel<MainModel>()
val settingsModel = hiltViewModel<SettingsModel>()
val state = screenModel.state.collectAsStateWithLifecycle() val state = screenModel.state.collectAsStateWithLifecycle()
val mainState = mainModel.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 { derivedStateOf {
listOf( val categoryIds = settingsState.value.categories.map { it.id }.toSet()
CategoryWithBooks( state.value.books.any { book ->
category = Category.READING, book.data.categories.none { category -> category in categoryIds }
title = UIText.StringResource(R.string.reading_tab), } || settingsState.value.categories.isEmpty()
books = state.value.books.filter { it.data.category == Category.READING } || mainState.value.libraryAlwaysShowDefaultTab
),
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 }
)
)
} }
} }
@ -107,12 +93,14 @@ object LibraryScreen : Screen, Parcelable {
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = initialPage initialPage = initialPage
) { categories.value.count() } ) {
settingsState.value.categories.count().plus(if (showDefaultCategory.value) 1 else 0)
}
DisposableEffect(Unit) { onDispose { initialPage = pagerState.currentPage } } DisposableEffect(Unit) { onDispose { initialPage = pagerState.currentPage } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
scrollToPageCompositionChannel.receiveAsFlow().collectLatest { scrollToPageCompositionChannel.receiveAsFlow().collectLatest {
pagerState.animateScrollToPage(it) pagerState.scrollToPage(it)
} }
} }
@ -120,6 +108,11 @@ object LibraryScreen : Screen, Parcelable {
books = state.value.books, books = state.value.books,
selectedItemsCount = state.value.selectedItemsCount, selectedItemsCount = state.value.selectedItemsCount,
hasSelectedItems = state.value.hasSelectedItems, 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, showSearch = state.value.showSearch,
searchQuery = state.value.searchQuery, searchQuery = state.value.searchQuery,
bookCount = state.value.books.count(), bookCount = state.value.books.count(),
@ -128,9 +121,21 @@ object LibraryScreen : Screen, Parcelable {
isLoading = state.value.isLoading, isLoading = state.value.isLoading,
isRefreshing = state.value.isRefreshing, isRefreshing = state.value.isRefreshing,
doublePressExit = mainState.value.doublePressExit, 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, refreshState = refreshState,
dialog = state.value.dialog, dialog = state.value.dialog,
bottomSheet = state.value.bottomSheet,
updateCategorySort = settingsModel::onEvent,
changeLibrarySortOrder = mainModel::onEvent,
changeLibrarySortOrderDescending = mainModel::onEvent,
selectBook = screenModel::onEvent, selectBook = screenModel::onEvent,
searchVisibility = screenModel::onEvent, searchVisibility = screenModel::onEvent,
requestFocus = screenModel::onEvent, requestFocus = screenModel::onEvent,
@ -141,6 +146,8 @@ object LibraryScreen : Screen, Parcelable {
actionMoveDialog = screenModel::onEvent, actionMoveDialog = screenModel::onEvent,
actionDeleteDialog = screenModel::onEvent, actionDeleteDialog = screenModel::onEvent,
showDeleteDialog = screenModel::onEvent, showDeleteDialog = screenModel::onEvent,
showFilterBottomSheet = screenModel::onEvent,
dismissBottomSheet = screenModel::onEvent,
dismissDialog = screenModel::onEvent, dismissDialog = screenModel::onEvent,
navigateToBrowse = { navigateToBrowse = {
navigator.push(BrowseScreen) navigator.push(BrowseScreen)
@ -151,6 +158,9 @@ object LibraryScreen : Screen, Parcelable {
}, },
navigateToBookInfo = { navigateToBookInfo = {
navigator.push(BookInfoScreen(bookId = it)) 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 androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.library.book.SelectableBook 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 import ua.acclorite.book_story.domain.util.Dialog
@Immutable @Immutable
@ -24,5 +25,6 @@ data class LibraryState(
val searchQuery: String = "", val searchQuery: String = "",
val hasFocused: Boolean = false, 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 OnChangeProgressCount(val value: String) : MainEvent()
data class OnChangeHorizontalGestureAlphaAnim(val value: Boolean) : MainEvent() data class OnChangeHorizontalGestureAlphaAnim(val value: Boolean) : MainEvent()
data class OnChangeHorizontalGesturePullAnim(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()
} }

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