🚀 Browse Improvement, Room migration

Big BrowseScreen improvement: Filter, sort, list/grid, favorite directories, all files / directories. Room migration 5 → 6.

Resolves: #14
This commit is contained in:
Acclorite 2024-08-19 15:00:03 +03:00
parent 07904d705c
commit 127d251ee6
56 changed files with 3408 additions and 542 deletions

View file

@ -0,0 +1,196 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "72a89d1aa5bd8ad59ce79dd1cbe3d5e5",
"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, `textPath` TEXT NOT NULL, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `category` TEXT NOT NULL)",
"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": "textPath",
"columnName": "textPath",
"affinity": "TEXT",
"notNull": true
},
{
"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": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
}
],
"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": "FavoriteDirectoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, PRIMARY KEY(`path`))",
"fields": [
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"path"
]
},
"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, '72a89d1aa5bd8ad59ce79dd1cbe3d5e5')"
]
}
}

View file

@ -40,6 +40,7 @@ import ua.acclorite.book_story.presentation.screens.reader.ReaderScreenRoot
import ua.acclorite.book_story.presentation.screens.settings.SettingsScreenRoot
import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.BrowseSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.reader.ReaderSettingsRoot
import ua.acclorite.book_story.presentation.screens.start.StartScreenRoot
@ -180,6 +181,9 @@ class Activity : AppCompatActivity() {
composable<Screen.Settings.ReaderSettings> {
ReaderSettingsRoot()
}
composable<Screen.Settings.BrowseSettings> {
BrowseSettingsRoot()
}
// About screen
composable<Screen.About> {

View file

@ -44,6 +44,7 @@ object AppModule {
.addMigrations(
DatabaseHelper.MIGRATION_2_3, // creates LanguageHistoryEntity table(if does not exist)
DatabaseHelper.MIGRATION_4_5, // creates ColorPresetEntity table(if does not exist)
DatabaseHelper.MIGRATION_5_6, // creates FavoriteDirectoryEntity table(if does not exist)
)
.allowMainThreadQueries()
.build()

View file

@ -0,0 +1,10 @@
package ua.acclorite.book_story.data.local.dto
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class FavoriteDirectoryEntity(
@PrimaryKey(autoGenerate = false)
val path: String
)

View file

@ -9,6 +9,7 @@ import androidx.room.Update
import androidx.room.Upsert
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity
@Dao
@ -78,4 +79,14 @@ interface BookDao {
@Query("DELETE FROM colorpresetentity")
suspend fun deleteColorPresets()
@Upsert
suspend fun insertFavoriteDirectory(favoriteDirectoryEntity: FavoriteDirectoryEntity)
@Query("SELECT EXISTS(SELECT 1 FROM favoritedirectoryentity WHERE path = :path LIMIT 1)")
suspend fun favoriteDirectoryExits(path: String): Boolean
@Delete
suspend fun deleteFavoriteDirectory(favoriteDirectoryEntity: FavoriteDirectoryEntity)
}

View file

@ -10,6 +10,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity
import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity
@Database(
@ -17,13 +18,15 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity
BookEntity::class,
HistoryEntity::class,
ColorPresetEntity::class,
FavoriteDirectoryEntity::class,
],
version = 5,
version = 6,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3),
AutoMigration(3, 4, spec = DatabaseHelper.MIGRATION_3_4::class),
AutoMigration(4, 5),
AutoMigration(5, 6),
],
exportSchema = true
)
@ -68,4 +71,15 @@ object DatabaseHelper {
)
}
}
val MIGRATION_5_6 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `FavoriteDirectoryEntity` (" +
"`path` TEXT NOT NULL, " +
"PRIMARY KEY(`path`)" +
")"
)
}
}
}

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.data_store.DataStore
import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity
import ua.acclorite.book_story.data.local.notification.UpdatesNotificationService
import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.book.BookMapper
@ -32,6 +33,7 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.ColorPreset
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.CoverImage
@ -464,97 +466,100 @@ class BookRepositoryImpl @Inject constructor(
return result.await()
}
override suspend fun getFilesFromDevice(query: String): Flow<Resource<List<File>>> {
fun getAllFilesInDirectory(directory: File): List<File> {
val filesList = mutableListOf<File>()
override suspend fun getFilesFromDevice(query: String): List<SelectableFile> {
val existingBooks = database
.searchBooks("")
.map { bookMapper.toBook(it) }
val supportedExtensions = Constants.EXTENSIONS
val files = directory.listFiles()
fun File.isValid(): Boolean {
if (!exists()) {
return false
}
val isFileSupported = supportedExtensions.any { ext ->
name.endsWith(
ext,
ignoreCase = true
)
}
if (!isFileSupported) {
return false
}
val isFileNotAdded = existingBooks.all {
it.filePath.lowercase().trim() != path.lowercase().trim()
}
if (!isFileNotAdded) {
return false
}
val isQuery = if (query.isEmpty()) true else name.trim().lowercase()
.contains(query.trim().lowercase())
return isQuery
}
suspend fun File.getAllFiles(): List<SelectableFile> {
val filesList = mutableListOf<SelectableFile>()
val files = listFiles()
if (files != null) {
for (file in files) {
if (file.isFile) {
filesList.add(file)
if (!file.exists()) {
continue
}
if (file.isDirectory) {
val subDirectoryFiles = getAllFilesInDirectory(file)
when {
file.isFile -> {
if (file.isValid()) {
filesList.add(
SelectableFile(
fileOrDirectory = file,
parentDirectory = this,
isDirectory = false,
isFavorite = false,
isSelected = false
)
)
}
}
file.isDirectory -> {
val subDirectoryFiles = file.getAllFiles()
if (subDirectoryFiles.isNotEmpty()) {
filesList.add(
SelectableFile(
fileOrDirectory = file,
parentDirectory = this,
isDirectory = true,
isFavorite = database.favoriteDirectoryExits(file.path),
isSelected = false
)
)
filesList.addAll(subDirectoryFiles)
}
}
}
}
}
return filesList
}
return flow {
val existingBooks = database
.searchBooks("")
.map { bookMapper.toBook(it) }
val primaryDirectory = Environment.getExternalStorageDirectory()
val rootDirectory = Environment.getExternalStorageDirectory()
if (
!primaryDirectory.exists() ||
!rootDirectory.exists() ||
!rootDirectory.isDirectory ||
(Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED &&
Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED_READ_ONLY)
) {
emit(Resource.Success(null))
return@flow
return emptyList()
}
val allFiles = getAllFilesInDirectory(primaryDirectory)
if (allFiles.isEmpty()) {
emit(Resource.Success(null))
return@flow
}
val supportedExtensions = Constants.EXTENSIONS
var filteredFiles = mutableListOf<File>()
allFiles.filter { file ->
val isFileSupported = supportedExtensions.any { ext ->
file.name.endsWith(
ext,
ignoreCase = true
)
}
if (!isFileSupported) {
return@filter false
}
val isFileNotAdded = existingBooks.all {
it.filePath.substringAfterLast("/").lowercase() !=
file.path.substringAfterLast("/").lowercase()
}
if (!isFileNotAdded) {
return@filter false
}
val isQuery = if (query.isEmpty()) true else file.name.trim().lowercase()
.contains(query.trim().lowercase())
if (!isQuery) {
return@filter false
}
true
}.forEach {
filteredFiles.add(
it
)
}
filteredFiles = filteredFiles.sortedByDescending {
it.lastModified()
}.toMutableList()
emit(
Resource.Success(
data = filteredFiles
)
)
}
return rootDirectory.getAllFiles()
}
override suspend fun getBooksFromFiles(files: List<File>): List<NullableBook> {
@ -729,6 +734,19 @@ class BookRepositoryImpl @Inject constructor(
)
)
}
override suspend fun updateFavoriteDirectory(path: String) {
if (database.favoriteDirectoryExits(path)) {
database.deleteFavoriteDirectory(
FavoriteDirectoryEntity(path)
)
return
}
database.insertFavoriteDirectory(
FavoriteDirectoryEntity(path)
)
}
}

View file

@ -0,0 +1,14 @@
package ua.acclorite.book_story.domain.model
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.util.Selected
import java.io.File
@Immutable
data class SelectableFile(
val fileOrDirectory: File,
val parentDirectory: File,
val isDirectory: Boolean,
val isFavorite: Boolean,
val isSelected: Selected
)

View file

@ -8,6 +8,7 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.ColorPreset
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.presentation.data.MainState
@ -71,7 +72,7 @@ interface BookRepository {
suspend fun getAllSettings(scope: CoroutineScope): MainState
suspend fun getFilesFromDevice(query: String = ""): Flow<Resource<List<File>>>
suspend fun getFilesFromDevice(query: String = ""): List<SelectableFile>
suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
@ -103,4 +104,7 @@ interface BookRepository {
suspend fun reorderColorPresets(orderedColorPresets: List<ColorPreset>)
suspend fun deleteColorPreset(colorPreset: ColorPreset)
suspend fun updateFavoriteDirectory(path: String)
}

View file

@ -1,15 +0,0 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.repository.BookRepository
import java.io.File
import javax.inject.Inject
class GetBooksFromFiles @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(files: List<File>): List<NullableBook> {
return repository.getBooksFromFiles(files)
}
}

View file

@ -1,16 +1,14 @@
package ua.acclorite.book_story.domain.use_case
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Resource
import java.io.File
import javax.inject.Inject
class GetFilesFromDevice @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(query: String): Flow<Resource<List<File>>> {
suspend fun execute(query: String): List<SelectableFile> {
return repository.getFilesFromDevice(query)
}
}

View file

@ -0,0 +1,10 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class UpdateFavoriteDirectory @Inject constructor(private val repository: BookRepository) {
suspend fun execute(path: String) {
return repository.updateFavoriteDirectory(path)
}
}

View file

@ -27,7 +27,7 @@ object Constants {
const val MAIN_STATE = "main_state"
// Supported file extensions
val EXTENSIONS = listOf(".txt", ".pdf", ".epub", ".fb2")
val EXTENSIONS = listOf(".epub", ".pdf", ".fb2", ".txt")
// Supported languages
val LANGUAGES = listOf(

View file

@ -3,23 +3,36 @@ package ua.acclorite.book_story.domain.util
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
object DataStoreConstants {
// App settings
// General settings
val LANGUAGE = stringPreferencesKey("language")
val THEME = stringPreferencesKey("theme")
val DARK_THEME = stringPreferencesKey("dark_theme")
val PURE_DARK = stringPreferencesKey("pure_dark")
val THEME_CONTRAST = stringPreferencesKey("theme_contrast")
val SHOW_START_SCREEN = booleanPreferencesKey("guide")
val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates")
// Reader settings
val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation")
val FAST_COLOR_PRESET_CHANGE = booleanPreferencesKey("fast_color_preset_change")
val SIDE_PADDING = intPreferencesKey("side_padding")
val FONT = stringPreferencesKey("font")
val IS_ITALIC = booleanPreferencesKey("font_style")
val FONT_SIZE = intPreferencesKey("font_size")
val LINE_HEIGHT = intPreferencesKey("line_height")
val PARAGRAPH_HEIGHT = intPreferencesKey("paragraph_height")
val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation")
val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates")
val SIDE_PADDING = intPreferencesKey("side_padding")
val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation")
val FAST_COLOR_PRESET_CHANGE = booleanPreferencesKey("fast_color_preset_change")
// Browse settings
val BROWSE_FILES_STRUCTURE = stringPreferencesKey("browse_files_structure")
val BROWSE_LAYOUT = stringPreferencesKey("browse_layout")
val BROWSE_AUTO_GRID_SIZE = booleanPreferencesKey("browse_auto_grid_size")
val BROWSE_GRID_SIZE = intPreferencesKey("browse_grid_size")
val BROWSE_PIN_FAVORITE_DIRECTORIES = booleanPreferencesKey("browse_pin_favorite_directories")
val BROWSE_SORT_ORDER = stringPreferencesKey("browse_sort_order")
val BROWSE_SORT_ORDER_DESCENDING = booleanPreferencesKey("browse_sort_order_descending")
val BROWSE_INCLUDED_FILTER_ITEMS = stringSetPreferencesKey("browse_included_filter_items")
}

View file

@ -1,5 +1,8 @@
package ua.acclorite.book_story.presentation.data
import androidx.compose.runtime.Immutable
@Immutable
sealed class MainEvent {
data class OnChangeLanguage(val lang: String) : MainEvent()
data class OnChangeTheme(val theme: String) : MainEvent()
@ -17,4 +20,12 @@ sealed class MainEvent {
data class OnChangeSidePadding(val sidePadding: Int) : MainEvent()
data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent()
data class OnChangeFastColorPresetChange(val bool: Boolean) : MainEvent()
data class OnChangeBrowseFilesStructure(val structure: String) : MainEvent()
data class OnChangeBrowseLayout(val layout: String) : MainEvent()
data class OnChangeBrowseAutoGridSize(val bool: Boolean) : MainEvent()
data class OnChangeBrowseGridSize(val size: Int) : MainEvent()
data class OnChangeBrowsePinFavoriteDirectories(val bool: Boolean) : MainEvent()
data class OnChangeBrowseSortOrder(val order: String) : MainEvent()
data class OnChangeBrowseSortOrderDescending(val bool: Boolean) : MainEvent()
data class OnChangeBrowseIncludedFilterItem(val item: String) : MainEvent()
}

View file

@ -1,3 +1,5 @@
@file:Suppress("UNCHECKED_CAST")
package ua.acclorite.book_story.presentation.data
import android.os.Build
@ -7,6 +9,12 @@ import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.DataStoreConstants
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseSortOrder
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseLayout
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseSortOrder
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toFilesStructure
import ua.acclorite.book_story.presentation.ui.DarkTheme
import ua.acclorite.book_story.presentation.ui.PureDark
import ua.acclorite.book_story.presentation.ui.Theme
@ -42,6 +50,14 @@ data class MainState(
val sidePadding: Int? = null,
val doubleClickTranslation: Boolean? = null,
val fastColorPresetChange: Boolean? = null,
val browseFilesStructure: BrowseFilesStructure? = null,
val browseLayout: BrowseLayout? = null,
val browseAutoGridSize: Boolean? = null,
val browseGridSize: Int? = null,
val browsePinFavoriteDirectories: Boolean? = null,
val browseSortOrder: BrowseSortOrder? = null,
val browseSortOrderDescending: Boolean? = null,
val browseIncludedFilterItems: List<String>? = null,
) : Parcelable {
companion object {
/**
@ -105,6 +121,37 @@ data class MainState(
data[FAST_COLOR_PRESET_CHANGE.name] as? Boolean
?: true
val browseFilesStructure: String = data[BROWSE_FILES_STRUCTURE.name] as? String
?: BrowseFilesStructure.DIRECTORIES.name
val browseLayout: String = data[BROWSE_LAYOUT.name] as? String
?: BrowseLayout.LIST.name
val browseAutoGridSize: Boolean =
data[BROWSE_AUTO_GRID_SIZE.name] as? Boolean
?: true
val browseGridSize: Int =
data[BROWSE_GRID_SIZE.name] as? Int
?: 0
val browsePinFavoriteDirectories: Boolean =
data[BROWSE_PIN_FAVORITE_DIRECTORIES.name] as? Boolean
?: true
val browseSortOrder: String =
data[BROWSE_SORT_ORDER.name] as? String
?: BrowseSortOrder.LAST_MODIFIED.name
val browseSortOrderDescending: Boolean =
data[BROWSE_SORT_ORDER_DESCENDING.name] as? Boolean
?: true
val browseIncludedFilterItems =
(data[BROWSE_INCLUDED_FILTER_ITEMS.name] as? Set<String>)?.toList()
?: emptyList()
return MainState(
language = language,
theme = theme.toTheme(),
@ -121,7 +168,15 @@ data class MainState(
checkForUpdates = checkForUpdates,
sidePadding = sidePadding,
doubleClickTranslation = doubleClickTranslation,
fastColorPresetChange = fastColorPresetChange
fastColorPresetChange = fastColorPresetChange,
browseFilesStructure = browseFilesStructure.toFilesStructure(),
browseLayout = browseLayout.toBrowseLayout(),
browseAutoGridSize = browseAutoGridSize,
browseGridSize = browseGridSize,
browsePinFavoriteDirectories = browsePinFavoriteDirectories,
browseSortOrder = browseSortOrder.toBrowseSortOrder(),
browseSortOrderDescending = browseSortOrderDescending,
browseIncludedFilterItems = browseIncludedFilterItems
)
}
}

View file

@ -20,6 +20,9 @@ import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.DataStoreConstants
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseLayout
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseSortOrder
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toFilesStructure
import ua.acclorite.book_story.presentation.ui.toDarkTheme
import ua.acclorite.book_story.presentation.ui.toPureDark
import ua.acclorite.book_story.presentation.ui.toTheme
@ -228,6 +231,105 @@ class MainViewModel @Inject constructor(
}
}
}
is MainEvent.OnChangeBrowseFilesStructure -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.BROWSE_FILES_STRUCTURE, event.structure)
updateStateWithSavedHandle {
it.copy(
browseFilesStructure = event.structure.toFilesStructure()
)
}
}
}
is MainEvent.OnChangeBrowseLayout -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.BROWSE_LAYOUT, event.layout)
updateStateWithSavedHandle {
it.copy(
browseLayout = event.layout.toBrowseLayout()
)
}
}
}
is MainEvent.OnChangeBrowseAutoGridSize -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.BROWSE_AUTO_GRID_SIZE, event.bool)
updateStateWithSavedHandle {
it.copy(
browseAutoGridSize = event.bool
)
}
}
}
is MainEvent.OnChangeBrowseGridSize -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.BROWSE_GRID_SIZE, event.size)
updateStateWithSavedHandle {
it.copy(
browseGridSize = event.size
)
}
}
}
is MainEvent.OnChangeBrowsePinFavoriteDirectories -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(
DataStoreConstants.BROWSE_PIN_FAVORITE_DIRECTORIES,
event.bool
)
updateStateWithSavedHandle {
it.copy(
browsePinFavoriteDirectories = event.bool
)
}
}
}
is MainEvent.OnChangeBrowseSortOrder -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(DataStoreConstants.BROWSE_SORT_ORDER, event.order)
updateStateWithSavedHandle {
it.copy(
browseSortOrder = event.order.toBrowseSortOrder()
)
}
}
}
is MainEvent.OnChangeBrowseSortOrderDescending -> {
viewModelScope.launch(Dispatchers.IO) {
setDatastore.execute(
DataStoreConstants.BROWSE_SORT_ORDER_DESCENDING,
event.bool
)
updateStateWithSavedHandle {
it.copy(
browseSortOrderDescending = event.bool
)
}
}
}
is MainEvent.OnChangeBrowseIncludedFilterItem -> {
viewModelScope.launch(Dispatchers.IO) {
val set = _state.value.browseIncludedFilterItems!!.toMutableSet()
if (!set.add(event.item)) {
set.remove(event.item)
}
setDatastore.execute(DataStoreConstants.BROWSE_INCLUDED_FILTER_ITEMS, set)
updateStateWithSavedHandle {
it.copy(
browseIncludedFilterItems = set.toList()
)
}
}
}
}
}

View file

@ -108,6 +108,10 @@ sealed class Screen : Parcelable {
@Parcelize
@Keep
data object ReaderSettings : Screen()
@Parcelize
@Keep
data object BrowseSettings : Screen()
}
@Parcelize

View file

@ -3,126 +3,125 @@ package ua.acclorite.book_story.presentation.screens.browse
import android.Manifest
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberPermissionState
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.CustomSearchTextField
import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.components.is_messages.IsError
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseFileItem
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseEmptyPlaceholder
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseStoragePermissionDialog
import ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog.BrowseAddingDialog
import ua.acclorite.book_story.presentation.screens.browse.components.filter_bottom_sheet.BrowseFilterBottomSheet
import ua.acclorite.book_story.presentation.screens.browse.components.layout.BrowseLayout
import ua.acclorite.book_story.presentation.screens.browse.components.top_bar.BrowseTopBar
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.ui.DefaultTransition
import ua.acclorite.book_story.presentation.ui.Transitions
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BrowseScreenRoot() {
val navigator = LocalNavigator.current
val viewModel: BrowseViewModel = hiltViewModel()
val libraryViewModel: LibraryViewModel = hiltViewModel()
val mainViewModel: MainViewModel = hiltViewModel()
val state = viewModel.state.collectAsState()
val mainState = mainViewModel.state.collectAsState()
BrowseScreen(
state = state,
onNavigate = { navigator.it() },
onEvent = viewModel::onEvent,
onLibraryEvent = libraryViewModel::onEvent
val permissionState = rememberPermissionState(
permission = Manifest.permission.READ_EXTERNAL_STORAGE
)
val filteredFiles = remember(
state.value.selectableFiles,
state.value.selectedDirectory
) {
derivedStateOf {
viewModel.filterList(mainState.value)
}
}
LaunchedEffect(Unit) {
viewModel.onEvent(BrowseEvent.OnUpdateScrollOffset)
viewModel.onEvent(
BrowseEvent.OnPermissionCheck(permissionState)
)
}
BrowseScreen(
state = state,
mainState = mainState,
permissionState = permissionState,
filteredFiles = filteredFiles.value,
onNavigate = { navigator.it() },
onEvent = viewModel::onEvent,
onMainEvent = mainViewModel::onEvent,
onLibraryEvent = libraryViewModel::onEvent
)
DisposableEffect(Unit) {
onDispose {
viewModel.clearViewModel()
}
}
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class,
ExperimentalMaterialApi::class
)
@Composable
private fun BrowseScreen(
state: State<BrowseState>,
mainState: State<MainState>,
permissionState: PermissionState,
filteredFiles: List<SelectableFile>,
onNavigate: OnNavigate,
onEvent: (BrowseEvent) -> Unit,
onMainEvent: (MainEvent) -> Unit,
onLibraryEvent: (LibraryEvent) -> Unit
) {
val context = LocalContext.current
val permissionState = rememberPermissionState(
permission = Manifest.permission.READ_EXTERNAL_STORAGE
)
val refreshState = rememberPullRefreshState(
refreshing = state.value.isRefreshing,
onRefresh = {
onEvent(BrowseEvent.OnRefreshList)
}
)
val focusRequester = remember { FocusRequester() }
var showErrorMessage by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
onEvent(BrowseEvent.OnUpdateScrollOffset)
onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
}
if (state.value.requestPermissionDialog) {
BrowseStoragePermissionDialog(onEvent, permissionState) {
showErrorMessage = it
}
BrowseStoragePermissionDialog(onEvent, permissionState)
}
if (state.value.showAddingDialog) {
BrowseAddingDialog(
@ -131,6 +130,14 @@ private fun BrowseScreen(
onLibraryEvent = onLibraryEvent
)
}
if (state.value.showFilterBottomSheet) {
BrowseFilterBottomSheet(
state = state,
mainState = mainState,
onMainEvent = onMainEvent,
onEvent = onEvent
)
}
Scaffold(
modifier = Modifier
@ -138,90 +145,11 @@ private fun BrowseScreen(
.pullRefresh(refreshState),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
AnimatedTopAppBar(
scrollBehavior = null,
isTopBarScrolled = state.value.hasSelectedItems || state.value.listState.canScrollBackward,
content1Visibility = !state.value.hasSelectedItems && !state.value.showSearch,
content1NavigationIcon = {},
content1Title = {
Text(stringResource(id = R.string.browse_screen))
},
content1Actions = {
CustomIconButton(
icon = Icons.Default.Search,
contentDescription = R.string.search_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnSearchShowHide)
}
MoreDropDown()
},
content2Visibility = state.value.hasSelectedItems,
content2NavigationIcon = {
CustomIconButton(
icon = Icons.Default.Clear,
contentDescription = R.string.clear_selected_items_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnClearSelectedFiles)
}
},
content2Title = {
Text(
stringResource(
id = R.string.selected_items_count_query,
state.value.selectedItemsCount.coerceAtLeast(1)
),
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
},
content2Actions = {
CustomIconButton(
icon = Icons.Default.Check,
contentDescription = R.string.add_files_content_desc,
disableOnClick = false,
enabled = !state.value.showAddingDialog
) {
onEvent(BrowseEvent.OnAddingDialogRequest)
}
},
content3Visibility = state.value.showSearch && !state.value.hasSelectedItems,
content3NavigationIcon = {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = R.string.exit_search_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnSearchShowHide)
}
},
content3Title = {
CustomSearchTextField(
modifier = Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
onEvent(BrowseEvent.OnRequestFocus(focusRequester))
},
query = state.value.searchQuery,
onQueryChange = {
onEvent(BrowseEvent.OnSearchQueryChange(it))
},
onSearch = {
onEvent(BrowseEvent.OnSearch)
},
placeholder = stringResource(
id = R.string.search_query,
stringResource(id = R.string.files)
)
)
},
content3Actions = {
MoreDropDown()
}
BrowseTopBar(
state = state,
mainState = mainState,
filteredFiles = filteredFiles,
onEvent = onEvent
)
}
) { padding ->
@ -231,86 +159,81 @@ private fun BrowseScreen(
.padding(top = padding.calculateTopPadding())
) {
DefaultTransition(visible = !state.value.isLoading) {
LazyColumn(
state = state.value.listState,
modifier = Modifier
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
customItems(
state.value.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem(
file = selectableFile,
modifier = Modifier.animateItem(),
hasSelectedFiles = state.value.selectableFiles.any { it.second },
onLongClick = {
BrowseLayout(
state = state,
mainState = mainState,
filteredFiles = filteredFiles,
onLongItemClick = { selectableFile ->
when (selectableFile.isDirectory) {
false -> {
Toast.makeText(
context,
context.getString(
R.string.file_path_query,
selectableFile.first.path
selectableFile.fileOrDirectory.path
),
Toast.LENGTH_LONG
).show()
},
onClick = {
onEvent(BrowseEvent.OnSelectFile(selectableFile))
}
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
CustomAnimatedVisibility(
visible = showErrorMessage,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = fadeOut(tween(0))
) {
IsError(
modifier = Modifier.align(Alignment.Center),
errorMessage = stringResource(id = R.string.error_permission),
icon = painterResource(id = R.drawable.error),
actionTitle = stringResource(id = R.string.grant_permission)
) {
true -> {
onEvent(
BrowseEvent.OnPermissionCheck(
permissionState,
hideErrorMessage = { showErrorMessage = false }
BrowseEvent.OnSelectFile(
includedFileFormats = mainState.value.browseIncludedFilterItems!!,
file = selectableFile
)
)
}
}
},
onFavoriteItemClick = {
onEvent(
BrowseEvent.OnUpdateFavoriteDirectory(
it.fileOrDirectory.path
)
)
},
onItemClick = { selectableFile ->
when (selectableFile.isDirectory) {
false -> {
onEvent(
BrowseEvent.OnSelectFile(
includedFileFormats = mainState.value.browseIncludedFilterItems!!,
file = selectableFile
)
)
}
CustomAnimatedVisibility(
visible = !state.value.isLoading && state.value.selectableFiles.isEmpty()
&& !showErrorMessage && !state.value.requestPermissionDialog
&& !state.value.isRefreshing,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = fadeOut(tween(0))
) {
IsEmpty(
message = stringResource(id = R.string.browse_empty),
icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.get_help),
action = {
onNavigate {
navigate(Screen.Help(false))
true -> {
if (!state.value.hasSelectedItems) {
onEvent(
BrowseEvent.OnChangeDirectory(
selectableFile.fileOrDirectory,
savePreviousDirectory = true
)
)
} else {
onEvent(
BrowseEvent.OnSelectFile(
includedFileFormats = mainState.value.browseIncludedFilterItems!!,
file = selectableFile
)
)
}
}
}
}
)
}
BrowseEmptyPlaceholder(
state = state,
isFilesEmpty = filteredFiles.isEmpty(),
storagePermissionState = permissionState,
onNavigate = onNavigate,
onEvent = onEvent
)
PullRefreshIndicator(
state.value.isRefreshing,
refreshState,
@ -332,17 +255,18 @@ private fun BrowseScreen(
return@BackHandler
}
if (
state.value.inNestedDirectory
&& mainState.value.browseFilesStructure != BrowseFilesStructure.ALL_FILES
) {
onEvent(
BrowseEvent.OnGoBackDirectory
)
return@BackHandler
}
onNavigate {
navigate(Screen.Library)
}
}
}

View file

@ -0,0 +1,80 @@
package ua.acclorite.book_story.presentation.screens.browse.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.components.is_messages.IsError
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.ui.Transitions
/**
* Browse Empty Placeholder.
* Shows error or empty message.
*
* @param state [BrowseState].
* @param isFilesEmpty Whether the list is empty.
* @param storagePermissionState Storage [PermissionState].
* @param onNavigate Navigator callback.
* @param onEvent [BrowseEvent] callback.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BoxScope.BrowseEmptyPlaceholder(
state: State<BrowseState>,
isFilesEmpty: Boolean,
storagePermissionState: PermissionState,
onNavigate: OnNavigate,
onEvent: (BrowseEvent) -> Unit
) {
CustomAnimatedVisibility(
visible = state.value.isError,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = Transitions.NoExitAnimation
) {
IsError(
modifier = Modifier.align(Alignment.Center),
errorMessage = stringResource(id = R.string.error_permission),
icon = painterResource(id = R.drawable.error),
actionTitle = stringResource(id = R.string.grant_permission)
) {
onEvent(
BrowseEvent.OnPermissionCheck(storagePermissionState)
)
}
}
CustomAnimatedVisibility(
visible = !state.value.isLoading
&& isFilesEmpty
&& !state.value.isError
&& !state.value.requestPermissionDialog
&& !state.value.isRefreshing,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = Transitions.NoExitAnimation
) {
IsEmpty(
message = stringResource(id = R.string.browse_empty),
icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.get_help),
action = {
onNavigate {
navigate(Screen.Help(false))
}
}
)
}
}

View file

@ -1,157 +0,0 @@
package ua.acclorite.book_story.presentation.screens.browse.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.Selected
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.presentation.ui.DefaultTransition
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Browse list element item. Can be selected.
*
* @param file [File].
* @param hasSelectedFiles Whether parent list has selected items.
* @param modifier Modifier.
* @param onClick OnClick callback.
* @param onLongClick OnLongClick callback.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BrowseFileItem(
file: Pair<File, Selected>,
hasSelectedFiles: Boolean,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
val lastModified = rememberSaveable { pattern.format(Date(file.first.lastModified())) }
val sizeBytes = rememberSaveable { file.first.length() }
val fileSizeKB = rememberSaveable { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 }
val fileSizeMB = rememberSaveable { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 }
val fileSize = rememberSaveable {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "0 KB"
}
val outlineColor = if (file.second) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant
val backgroundColor = if (file.second) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 3.dp)
.clip(RoundedCornerShape(10.dp))
.background(backgroundColor, RoundedCornerShape(10.dp))
.combinedClickable(
onLongClick = {
onLongClick()
},
onClick = {
onClick()
}
)
.padding(horizontal = 8.dp, vertical = 7.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(0.88f)
) {
Box(
modifier = Modifier
.border(
1.dp,
outlineColor,
RoundedCornerShape(6.dp)
)
.padding(14.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = stringResource(id = R.string.file_icon_content_desc),
modifier = Modifier
.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.Center) {
Text(
file.first.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"$fileSize, $lastModified",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
Box(
modifier = Modifier
.weight(0.12f)
.padding(end = 6.dp),
contentAlignment = Alignment.CenterEnd
) {
DefaultTransition(
visible = hasSelectedFiles
) {
CustomCheckbox(
selected = file.second,
containerColor = MaterialTheme.colorScheme.surface,
size = 18.dp
)
}
}
}
}

View file

@ -14,13 +14,15 @@ import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
/**
* Storage permission dialog.
*
* @param onEvent [BrowseEvent] callback.
* @param permissionState Storage [PermissionState].
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BrowseStoragePermissionDialog(
onEvent: (BrowseEvent) -> Unit,
permissionState: PermissionState,
showErrorMessage: (Boolean) -> Unit
permissionState: PermissionState
) {
val activity = LocalContext.current as ComponentActivity
@ -32,8 +34,7 @@ fun BrowseStoragePermissionDialog(
onDismiss = {
onEvent(
BrowseEvent.OnStoragePermissionDismiss(
permissionState,
showErrorMessage = { showErrorMessage(true) }
permissionState = permissionState
)
)
},
@ -43,9 +44,8 @@ fun BrowseStoragePermissionDialog(
onAction = {
onEvent(
BrowseEvent.OnStoragePermissionRequest(
activity,
storagePermissionState = permissionState,
hideErrorMessage = { showErrorMessage(false) }
activity = activity,
storagePermissionState = permissionState
)
)
}

View file

@ -0,0 +1,105 @@
package ua.acclorite.book_story.presentation.screens.browse.components.filter_bottom_sheet
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.components.CustomBottomSheet
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseFilterSubcategory
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseGeneralSubcategory
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseSortSubcategory
/**
* Browse Filter Bottom Sheet.
* Lets user sort, filter and change appearance of the Browse.
*
* @param state [BrowseState].
* @param mainState [MainState].
* @param onMainEvent [MainEvent] callback.
* @param onEvent [BrowseEvent] callback.
*/
@Composable
fun BrowseFilterBottomSheet(
state: State<BrowseState>,
mainState: State<MainState>,
onMainEvent: (MainEvent) -> Unit,
onEvent: (BrowseEvent) -> Unit
) {
val pagerState = rememberPagerState(state.value.currentPage) { 3 }
LaunchedEffect(pagerState.currentPage) {
onEvent(
BrowseEvent.OnScrollToFilterPage(
page = pagerState.currentPage,
pagerState = null
)
)
}
CustomBottomSheet(
hasFixedHeight = true,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
dragHandle = {},
onDismissRequest = {
onEvent(BrowseEvent.OnShowHideFilterBottomSheet)
}
) {
BrowseFilterBottomSheetTabRow(
onEvent = onEvent,
pagerState = pagerState
)
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
when (page) {
0 -> {
LazyColumn(Modifier.fillMaxSize()) {
BrowseGeneralSubcategory(
state = mainState,
onMainEvent = onMainEvent,
showTitle = false,
topPadding = 16.dp,
bottomPadding = 8.dp + it
)
}
}
1 -> {
LazyColumn(Modifier.fillMaxSize()) {
BrowseFilterSubcategory(
state = mainState,
onMainEvent = onMainEvent,
showTitle = false,
topPadding = 16.dp,
bottomPadding = 8.dp + it
)
}
}
2 -> {
LazyColumn(Modifier.fillMaxSize()) {
BrowseSortSubcategory(
state = mainState,
onMainEvent = onMainEvent,
showTitle = false,
topPadding = 16.dp,
bottomPadding = 8.dp + it
)
}
}
}
}
}
}

View file

@ -0,0 +1,61 @@
package ua.acclorite.book_story.presentation.screens.browse.components.filter_bottom_sheet
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
/**
* Browse Filter Bottom Sheet Tab Row.
* Lets user switch tabs in bottom sheet.
*
* @param onEvent [BrowseEvent] callback.
* @param pagerState [PagerState].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BrowseFilterBottomSheetTabRow(
onEvent: (BrowseEvent) -> Unit,
pagerState: PagerState
) {
val tabItems = listOf(
stringResource(id = R.string.general_tab),
stringResource(id = R.string.filter_tab),
stringResource(id = R.string.sort_tab)
)
PrimaryTabRow(
modifier = Modifier
.fillMaxWidth(),
selectedTabIndex = pagerState.currentPage,
containerColor = Color.Transparent
) {
tabItems.forEachIndexed { index, tabItem ->
Tab(
modifier = Modifier,
selected = pagerState.currentPage == index,
onClick = {
onEvent(BrowseEvent.OnScrollToFilterPage(index, pagerState))
},
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
text = {
Text(
tabItem,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
}
)
}
}
}

View file

@ -0,0 +1,53 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.screens.browse.components.layout.grid.BrowseGridItem
import ua.acclorite.book_story.presentation.screens.browse.components.layout.list.BrowseListItem
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
import java.io.File
/**
* Browse list element item. Can be selected.
*
* @param file [File].
* @param hasSelectedFiles Whether parent list has selected items.
* @param modifier Modifier.
* @param onClick OnClick callback.
* @param onLongClick OnLongClick callback.
*/
@Composable
fun BrowseItem(
layout: BrowseLayout,
file: SelectableFile,
hasSelectedFiles: Boolean,
modifier: Modifier,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
onLongClick: () -> Unit
) {
when (layout) {
BrowseLayout.LIST -> {
BrowseListItem(
modifier = modifier,
file = file,
hasSelectedFiles = hasSelectedFiles,
onClick = onClick,
onFavoriteClick = onFavoriteClick,
onLongClick = onLongClick
)
}
BrowseLayout.GRID -> {
BrowseGridItem(
modifier = modifier,
file = file,
hasSelectedFiles = hasSelectedFiles,
onClick = onClick,
onFavoriteClick = onFavoriteClick,
onLongClick = onLongClick
)
}
}
}

View file

@ -0,0 +1,55 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.browse.components.layout.grid.BrowseGridLayout
import ua.acclorite.book_story.presentation.screens.browse.components.layout.list.BrowseListLayout
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
/**
* Browse Layout.
* Shows Grid or List.
*
* @param state [BrowseState].
* @param mainState [MainState].
* @param filteredFiles Filtered items.
* @param onLongItemClick OnLongItemClick callback.
* @param onFavoriteItemClick OnFavoriteItemClick callback.
* @param onItemClick OnItemClick callback.
*/
@Composable
fun BrowseLayout(
state: State<BrowseState>,
mainState: State<MainState>,
filteredFiles: List<SelectableFile>,
onLongItemClick: (SelectableFile) -> Unit,
onFavoriteItemClick: (SelectableFile) -> Unit,
onItemClick: (SelectableFile) -> Unit
) {
when (mainState.value.browseLayout!!) {
BrowseLayout.LIST -> {
BrowseListLayout(
state = state,
filteredFiles = filteredFiles,
onLongItemClick = onLongItemClick,
onFavoriteItemClick = onFavoriteItemClick,
onItemClick = onItemClick
)
}
BrowseLayout.GRID -> {
BrowseGridLayout(
state = state,
gridSize = mainState.value.browseGridSize!!,
autoGridSize = mainState.value.browseAutoGridSize!!,
filteredFiles = filteredFiles,
onLongItemClick = onLongItemClick,
onFavoriteItemClick = onFavoriteItemClick,
onItemClick = onItemClick
)
}
}
}

View file

@ -0,0 +1,128 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.grid
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.presentation.ui.DefaultTransition
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun BrowseGridDirectoryItem(
file: SelectableFile,
hasSelectedFiles: Boolean,
onFavoriteClick: () -> Unit
) {
val lastModified = rememberSaveable {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
.format(Date(file.fileOrDirectory.lastModified()))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.border(
1.dp,
if (file.isSelected) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(10.dp)
)
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = stringResource(id = R.string.directory_icon_content_desc),
modifier = Modifier
.fillMaxWidth(0.3f)
.aspectRatio(1f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
DefaultTransition(
visible = hasSelectedFiles,
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
) {
CustomCheckbox(
selected = file.isSelected,
containerColor = MaterialTheme.colorScheme.surface,
size = 18.dp
)
}
DefaultTransition(
visible = !hasSelectedFiles,
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
) {
Icon(
imageVector = if (file.isFavorite) Icons.Default.Favorite
else Icons.Default.FavoriteBorder,
contentDescription = stringResource(
id = R.string.favorite_directory_content_desc
),
modifier = Modifier
.size(24.dp)
.clickable(
enabled = !hasSelectedFiles,
interactionSource = null,
indication = null
) {
onFavoriteClick()
},
tint = if (file.isFavorite) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(0.8f)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
file.fileOrDirectory.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
lastModified,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,107 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.grid
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.presentation.ui.DefaultTransition
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun BrowseGridFileItem(file: SelectableFile, hasSelectedFiles: Boolean) {
val lastModified = rememberSaveable {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
.format(Date(file.fileOrDirectory.lastModified()))
}
val sizeBytes = rememberSaveable { file.fileOrDirectory.length() }
val fileSizeKB = rememberSaveable {
if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
}
val fileSizeMB = rememberSaveable {
if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0
}
val fileSize = rememberSaveable {
if (fileSizeMB >= 1.0) "%.0f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.0f KB".format(fileSizeKB)
else "0 KB"
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.border(
1.dp,
if (file.isSelected) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(10.dp)
)
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = stringResource(id = R.string.file_icon_content_desc),
modifier = Modifier
.fillMaxWidth(0.3f)
.aspectRatio(1f),
tint = MaterialTheme.colorScheme.primary
)
DefaultTransition(
visible = hasSelectedFiles,
modifier = Modifier.align(Alignment.TopStart)
) {
CustomCheckbox(
selected = file.isSelected,
containerColor = MaterialTheme.colorScheme.surface,
size = 18.dp,
modifier = Modifier.padding(12.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
file.fileOrDirectory.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
"$fileSize, $lastModified",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,61 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.grid
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.SelectableFile
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BrowseGridItem(
modifier: Modifier,
file: SelectableFile,
hasSelectedFiles: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
onLongClick: () -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(3.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (file.isSelected) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent,
RoundedCornerShape(12.dp)
)
.combinedClickable(
onLongClick = onLongClick,
onClick = onClick
)
.padding(5.dp)
) {
when {
!file.isDirectory -> {
BrowseGridFileItem(
file = file,
hasSelectedFiles = hasSelectedFiles
)
}
file.isDirectory -> {
BrowseGridDirectoryItem(
file = file,
hasSelectedFiles = hasSelectedFiles,
onFavoriteClick = onFavoriteClick
)
}
}
}
}

View file

@ -0,0 +1,67 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.grid
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.components.header
import ua.acclorite.book_story.presentation.screens.browse.components.layout.BrowseItem
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
@Composable
fun BrowseGridLayout(
state: State<BrowseState>,
gridSize: Int,
autoGridSize: Boolean,
filteredFiles: List<SelectableFile>,
onLongItemClick: (SelectableFile) -> Unit,
onFavoriteItemClick: (SelectableFile) -> Unit,
onItemClick: (SelectableFile) -> Unit,
) {
LazyVerticalGrid(
columns = if (autoGridSize) GridCells.Adaptive(170.dp)
else GridCells.Fixed(gridSize.coerceAtLeast(1)),
state = state.value.gridState,
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
header {
Spacer(modifier = Modifier.height(8.dp))
}
customItems(
filteredFiles,
key = { it.fileOrDirectory.path }
) { selectableFile ->
BrowseItem(
file = selectableFile,
modifier = Modifier.animateItem(),
layout = BrowseLayout.GRID,
hasSelectedFiles = state.value.selectableFiles.any { it.isSelected },
onLongClick = {
onLongItemClick(selectableFile)
},
onFavoriteClick = {
onFavoriteItemClick(selectableFile)
},
onClick = {
onItemClick(selectableFile)
}
)
}
header {
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View file

@ -0,0 +1,73 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.Folder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun RowScope.BrowseListDirectoryItem(file: SelectableFile) {
val lastModified = rememberSaveable {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
.format(Date(file.fileOrDirectory.lastModified()))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = stringResource(id = R.string.directory_icon_content_desc),
modifier = Modifier
.padding(14.dp)
.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.Center) {
Text(
file.fileOrDirectory.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(2.dp))
Text(
lastModified,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View file

@ -0,0 +1,100 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.list
import androidx.compose.foundation.border
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.RowScope
import androidx.compose.foundation.layout.Spacer
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun RowScope.BrowseListFileItem(file: SelectableFile) {
val lastModified = rememberSaveable {
SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
.format(Date(file.fileOrDirectory.lastModified()))
}
val sizeBytes = rememberSaveable { file.fileOrDirectory.length() }
val fileSizeKB = rememberSaveable {
if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
}
val fileSizeMB = rememberSaveable {
if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0
}
val fileSize = rememberSaveable {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "0 KB"
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.border(
1.dp,
if (file.isSelected) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(6.dp)
)
.padding(14.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = stringResource(id = R.string.file_icon_content_desc),
modifier = Modifier
.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.Center) {
Text(
file.fileOrDirectory.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(2.dp))
Text(
"$fileSize, $lastModified",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View file

@ -0,0 +1,133 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.list
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
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.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.model.SelectableFile
import ua.acclorite.book_story.presentation.components.CustomCheckbox
import ua.acclorite.book_story.presentation.ui.FadeTransitionPreservingSpace
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BrowseListItem(
modifier: Modifier,
file: SelectableFile,
hasSelectedFiles: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
onLongClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 3.dp)
.clip(RoundedCornerShape(10.dp))
.background(
if (file.isSelected) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent,
RoundedCornerShape(10.dp)
)
.combinedClickable(
onLongClick = {
onLongClick()
},
onClick = {
onClick()
}
)
.padding(horizontal = 8.dp, vertical = 7.dp)
) {
when {
!file.isDirectory -> {
BrowseListFileItem(file = file)
FadeTransitionPreservingSpace(
visible = hasSelectedFiles,
animationSpec = tween(300)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(16.dp))
CustomCheckbox(
selected = file.isSelected,
containerColor = MaterialTheme.colorScheme.surface,
size = 18.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
file.isDirectory -> {
BrowseListDirectoryItem(file = file)
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(16.dp))
Box(contentAlignment = Alignment.Center) {
FadeTransitionPreservingSpace(
hasSelectedFiles,
animationSpec = tween(300)
) {
CustomCheckbox(
selected = file.isSelected,
containerColor = MaterialTheme.colorScheme.surface,
size = 18.dp
)
}
FadeTransitionPreservingSpace(
!hasSelectedFiles,
animationSpec = tween(300)
) {
Icon(
imageVector = if (file.isFavorite) Icons.Default.Favorite
else Icons.Default.FavoriteBorder,
contentDescription = stringResource(
id = R.string.favorite_directory_content_desc
),
modifier = Modifier
.size(24.dp)
.clickable(
enabled = !hasSelectedFiles,
interactionSource = null,
indication = null
) {
onFavoriteClick()
},
tint = if (file.isFavorite) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(0.8f)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}

View file

@ -0,0 +1,59 @@
package ua.acclorite.book_story.presentation.screens.browse.components.layout.list
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.screens.browse.components.layout.BrowseItem
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
@Composable
fun BrowseListLayout(
state: State<BrowseState>,
filteredFiles: List<SelectableFile>,
onLongItemClick: (SelectableFile) -> Unit,
onFavoriteItemClick: (SelectableFile) -> Unit,
onItemClick: (SelectableFile) -> Unit,
) {
LazyColumn(
state = state.value.listState,
modifier = Modifier
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
customItems(
filteredFiles,
key = { it.fileOrDirectory.path }
) { selectableFile ->
BrowseItem(
file = selectableFile,
layout = BrowseLayout.LIST,
modifier = Modifier.animateItem(),
hasSelectedFiles = state.value.selectableFiles.any { it.isSelected },
onLongClick = {
onLongItemClick(selectableFile)
},
onFavoriteClick = {
onFavoriteItemClick(selectableFile)
},
onClick = {
onItemClick(selectableFile)
}
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View file

@ -0,0 +1,250 @@
package ua.acclorite.book_story.presentation.screens.browse.components.top_bar
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
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.SelectAll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.CustomIconButton
import ua.acclorite.book_story.presentation.components.CustomSearchTextField
import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
/**
* Browse Top Bar.
*
* @param state [BrowseState].
* @param mainState [MainState].
* @param filteredFiles Filtered files.
* @param onEvent [BrowseEvent] callback.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BrowseTopBar(
state: State<BrowseState>,
mainState: State<MainState>,
filteredFiles: List<SelectableFile>,
onEvent: (BrowseEvent) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val selectedDirectoryName = remember {
mutableStateOf(state.value.selectedDirectory.name)
}
val inNestedDirectory = remember {
derivedStateOf {
state.value.inNestedDirectory
&& mainState.value.browseFilesStructure != BrowseFilesStructure.ALL_FILES
}
}
val isScrolled = remember {
derivedStateOf {
when (mainState.value.browseLayout!!) {
BrowseLayout.LIST -> state.value.listState.canScrollBackward
BrowseLayout.GRID -> state.value.gridState.canScrollBackward
}
}
}
LaunchedEffect(
state.value.inNestedDirectory,
state.value.selectedDirectory
) {
if (state.value.inNestedDirectory) {
selectedDirectoryName.value = state.value.selectedDirectory.name
}
}
AnimatedTopAppBar(
scrollBehavior = null,
isTopBarScrolled = state.value.hasSelectedItems ||
isScrolled.value ||
inNestedDirectory.value,
content1Visibility = !state.value.hasSelectedItems && !state.value.showSearch,
content1NavigationIcon = {
CustomAnimatedVisibility(
visible = inNestedDirectory.value,
enter = expandHorizontally(tween(200))
+ slideInHorizontally(tween(200))
+ fadeIn(tween(250)),
exit = shrinkHorizontally(tween(150))
+ slideOutHorizontally(tween(150))
+ fadeOut(tween(200))
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = R.string.go_back_content_desc,
disableOnClick = false,
enabled = inNestedDirectory.value
) {
onEvent(BrowseEvent.OnGoBackDirectory)
}
}
},
content1Title = {
Crossfade(
targetState = inNestedDirectory.value,
animationSpec = tween(200),
label = ""
) {
if (it) {
Text(
selectedDirectoryName.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
stringResource(id = R.string.browse_screen),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
content1Actions = {
CustomIconButton(
icon = Icons.Default.Search,
contentDescription = R.string.search_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnSearchShowHide)
}
CustomIconButton(
icon = Icons.Default.FilterList,
contentDescription = R.string.filter_content_desc,
disableOnClick = false,
color = animateColorAsState(
if (mainState.value.browseIncludedFilterItems!!.isNotEmpty()) {
MaterialTheme.colorScheme.primary
} else LocalContentColor.current,
label = ""
).value,
enabled = !state.value.showFilterBottomSheet
) {
onEvent(BrowseEvent.OnShowHideFilterBottomSheet)
}
MoreDropDown()
},
content2Visibility = state.value.hasSelectedItems,
content2NavigationIcon = {
CustomIconButton(
icon = Icons.Default.Clear,
contentDescription = R.string.clear_selected_items_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnClearSelectedFiles)
}
},
content2Title = {
Text(
stringResource(
id = R.string.selected_items_count_query,
state.value.selectedItemsCount.coerceAtLeast(1)
),
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
},
content2Actions = {
CustomIconButton(
icon = Icons.Default.SelectAll,
contentDescription = R.string.select_all_files_content_desc,
disableOnClick = false,
) {
onEvent(
BrowseEvent.OnSelectFiles(
includedFileFormats = mainState.value.browseIncludedFilterItems!!,
files = filteredFiles
)
)
}
CustomIconButton(
icon = Icons.Default.Check,
contentDescription = R.string.add_files_content_desc,
disableOnClick = false,
enabled = !state.value.showAddingDialog
) {
onEvent(BrowseEvent.OnAddingDialogRequest)
}
},
content3Visibility = state.value.showSearch && !state.value.hasSelectedItems,
content3NavigationIcon = {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = R.string.exit_search_content_desc,
disableOnClick = true
) {
onEvent(BrowseEvent.OnSearchShowHide)
}
},
content3Title = {
CustomSearchTextField(
modifier = Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
onEvent(BrowseEvent.OnRequestFocus(focusRequester))
},
query = state.value.searchQuery,
onQueryChange = {
onEvent(BrowseEvent.OnSearchQueryChange(it))
},
onSearch = {
onEvent(BrowseEvent.OnSearch)
},
placeholder = stringResource(
id = R.string.search_query,
stringResource(id = R.string.files)
)
)
},
content3Actions = {
MoreDropDown()
},
customContent = {
BrowseTopBarDirectoryPath(
state = state,
mainState = mainState,
onEvent = onEvent
)
}
)
}

View file

@ -0,0 +1,147 @@
package ua.acclorite.book_story.presentation.screens.browse.components.top_bar
import android.os.Environment
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
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.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.customItemsIndexed
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import java.io.File
@Composable
fun BrowseTopBarDirectoryPath(
state: State<BrowseState>,
mainState: State<MainState>,
onEvent: (BrowseEvent) -> Unit
) {
val context = LocalContext.current
val rootDirectory = remember {
Environment.getExternalStorageDirectory()
}
val directories = remember(state.value.selectedDirectory) {
val directories = mutableListOf<Pair<String, File>>()
if (state.value.selectedDirectory == rootDirectory) {
return@remember listOf(
context.getString(R.string.internal_storage) to rootDirectory
)
}
directories.add(state.value.selectedDirectory.name to state.value.selectedDirectory)
var currentDirectory = state.value.selectedDirectory.parentFile
?: return@remember emptyList()
while (true) {
if (currentDirectory == rootDirectory) {
break
}
directories.add(
currentDirectory.name to currentDirectory
)
currentDirectory = currentDirectory.parentFile ?: continue
}
directories.add(
context.getString(R.string.internal_storage) to rootDirectory
)
directories.reversed()
}
val listState = rememberLazyListState(directories.lastIndex)
LaunchedEffect(directories) {
try {
listState.scrollToItem(directories.lastIndex)
} catch (e: Exception) {
e.printStackTrace()
}
}
CustomAnimatedVisibility(
visible = !state.value.hasSelectedItems
&& !state.value.hasSearched
&& !state.value.isError
&& mainState.value.browseFilesStructure == BrowseFilesStructure.DIRECTORIES,
enter = expandVertically(),
exit = shrinkVertically()
) {
LazyRow(
Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
state = listState,
verticalAlignment = Alignment.CenterVertically
) {
customItemsIndexed(directories, key = { _, index -> index }) { index, directory ->
if (index == 0) {
Spacer(modifier = Modifier.width(16.dp))
}
Text(
text = directory.first,
color = animateColorAsState(
if (index == directories.lastIndex) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
label = ""
).value,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.clickable(
enabled = state.value.selectedDirectory != directory.second,
indication = null,
interactionSource = null
) {
onEvent(
BrowseEvent.OnChangeDirectory(
directory.second,
savePreviousDirectory = false
)
)
}
)
if (index < directories.lastIndex) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowRight,
contentDescription = stringResource(id = R.string.path_arrow_icon_content_desc),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
} else {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
}
}

View file

@ -3,11 +3,13 @@
package ua.acclorite.book_story.presentation.screens.browse.data
import androidx.activity.ComponentActivity
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.domain.util.Selected
import java.io.File
@ -16,22 +18,28 @@ import java.io.File
sealed class BrowseEvent {
data class OnStoragePermissionRequest(
val activity: ComponentActivity,
val storagePermissionState: PermissionState,
val hideErrorMessage: () -> Unit
val storagePermissionState: PermissionState
) : BrowseEvent()
data class OnStoragePermissionDismiss(
val permissionState: PermissionState,
val showErrorMessage: () -> Unit
val permissionState: PermissionState
) : BrowseEvent()
data object OnRefreshList : BrowseEvent()
data class OnPermissionCheck(
val permissionState: PermissionState,
val hideErrorMessage: () -> Unit
val permissionState: PermissionState
) : BrowseEvent()
data class OnSelectFile(
val includedFileFormats: List<String>,
val file: SelectableFile
) : BrowseEvent()
data class OnSelectFiles(
val includedFileFormats: List<String>,
val files: List<SelectableFile>
) : BrowseEvent()
data class OnSelectFile(val file: Pair<File, Selected>) : BrowseEvent()
data class OnSelectBook(val book: Pair<NullableBook, Selected>) : BrowseEvent()
data object OnSearchShowHide : BrowseEvent()
data class OnRequestFocus(val focusRequester: FocusRequester) : BrowseEvent()
@ -50,4 +58,11 @@ sealed class BrowseEvent {
data object OnLoadList : BrowseEvent()
data object OnUpdateScrollOffset : BrowseEvent()
data class OnChangeDirectory(val directory: File, val savePreviousDirectory: Boolean) :
BrowseEvent()
data object OnGoBackDirectory : BrowseEvent()
data object OnShowHideFilterBottomSheet : BrowseEvent()
data class OnScrollToFilterPage(val page: Int, val pagerState: PagerState?) : BrowseEvent()
data class OnUpdateFavoriteDirectory(val path: String) : BrowseEvent()
}

View file

@ -1,18 +1,29 @@
package ua.acclorite.book_story.presentation.screens.browse.data
import android.os.Environment
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.Selected
import java.io.File
@Immutable
data class BrowseState(
val selectableFiles: List<Pair<File, Selected>> = emptyList(),
val listState: LazyListState = LazyListState(0, 0),
val selectableFiles: List<SelectableFile> = emptyList(),
val listState: LazyListState = LazyListState(),
val gridState: LazyGridState = LazyGridState(),
val selectedDirectory: File = Environment.getExternalStorageDirectory(),
val previousDirectory: File? = null,
val inNestedDirectory: Boolean = false,
val showFilterBottomSheet: Boolean = false,
val currentPage: Int = 1,
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val isError: Boolean = false,
val requestPermissionDialog: Boolean = false,
val selectedItemsCount: Int = 0,
@ -20,6 +31,7 @@ data class BrowseState(
val showSearch: Boolean = false,
val searchQuery: String = "",
val hasSearched: Boolean = false,
val hasFocused: Boolean = false,
val showAddingDialog: Boolean = false,

View file

@ -1,3 +1,5 @@
@file:Suppress("LABEL_NAME_CLASH")
package ua.acclorite.book_story.presentation.screens.browse.data
import android.content.Intent
@ -6,6 +8,7 @@ import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -21,35 +24,43 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.use_case.GetBooksFromFiles
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.use_case.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.GetFilesFromDevice
import ua.acclorite.book_story.domain.use_case.InsertBook
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.use_case.UpdateFavoriteDirectory
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.data.launchActivity
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseSortOrder
import javax.inject.Inject
@OptIn(ExperimentalPermissionsApi::class)
@HiltViewModel
class BrowseViewModel @Inject constructor(
private val getBooksFromFiles: GetBooksFromFiles,
private val getBookFromFile: GetBookFromFile,
private val getFilesFromDevice: GetFilesFromDevice,
private val insertBook: InsertBook
private val insertBook: InsertBook,
private val updateFavoriteDirectory: UpdateFavoriteDirectory,
) : ViewModel() {
private val _state = MutableStateFlow(BrowseState())
val state = _state.asStateFlow()
private var job: Job? = null
private var job2: Job? = null
private var job3: Job? = null
private var searchQueryJob: Job? = null
private var refreshListJob: Job? = null
private var getBooksJob: Job? = null
private var storagePermissionJob: Job? = null
private var changeDirectoryJob: Job? = null
init {
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
isLoading = true,
selectedDirectory = Environment.getExternalStorageDirectory(),
inNestedDirectory = false
)
}
getFilesFromDownloads()
@ -70,10 +81,10 @@ class BrowseViewModel @Inject constructor(
if (isPermissionGranted) {
_state.update {
it.copy(
requestPermissionDialog = false
requestPermissionDialog = false,
isError = false
)
}
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList)
return
}
@ -130,10 +141,10 @@ class BrowseViewModel @Inject constructor(
_state.update {
it.copy(
requestPermissionDialog = false
requestPermissionDialog = false,
isError = false
)
}
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList)
break
}
@ -158,19 +169,24 @@ class BrowseViewModel @Inject constructor(
getFilesFromDownloads()
}
} else {
event.showErrorMessage()
_state.update {
it.copy(
isError = true
)
}
}
}
is BrowseEvent.OnRefreshList -> {
job2?.cancel()
job2 = viewModelScope.launch(Dispatchers.IO) {
refreshListJob?.cancel()
refreshListJob = viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isRefreshing = true,
hasSelectedItems = false,
showSearch = false,
listState = LazyListState(0, 0)
listState = LazyListState(),
gridState = LazyGridState(),
)
}
yield()
@ -197,30 +213,94 @@ class BrowseViewModel @Inject constructor(
_state.update {
it.copy(
requestPermissionDialog = true
requestPermissionDialog = true,
isError = false
)
}
event.hideErrorMessage()
}
}
is BrowseEvent.OnSelectFile -> {
viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.selectableFiles.map {
if (event.file.first.path == it.first.path) {
it.copy(
second = !it.second
val editedList = _state.value.selectableFiles.map { file ->
when (event.file.isDirectory) {
false -> {
if (event.file.fileOrDirectory.path == file.fileOrDirectory.path) {
file.copy(
isSelected = !file.isSelected
)
} else {
it
file
}
}
true -> {
if (
file.fileOrDirectory.path.startsWith(
event.file.fileOrDirectory.path
) && event.includedFileFormats.run {
if (isEmpty()) return@run true
any {
file.fileOrDirectory.path.endsWith(
it, ignoreCase = true
) || file.isDirectory
}
}
) {
file.copy(
isSelected = !event.file.isSelected
)
} else {
file
}
}
}
}
_state.update {
it.copy(
selectableFiles = editedList,
selectedItemsCount = editedList.filter { file -> file.second }.size,
hasSelectedItems = editedList.any { file -> file.second }
selectedItemsCount = editedList.filter { file ->
file.isSelected && !file.isDirectory
}.size,
hasSelectedItems = editedList.any { file ->
file.isSelected && !file.isDirectory
}
)
}
}
}
is BrowseEvent.OnSelectFiles -> {
viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.selectableFiles.map { file ->
if (
event.files.any {
file.fileOrDirectory.path.startsWith(it.fileOrDirectory.path)
} && event.includedFileFormats.run {
if (isEmpty()) return@run true
any {
file.fileOrDirectory.path.endsWith(
it, ignoreCase = true
) || file.isDirectory
}
}
) {
file.copy(isSelected = true)
} else {
file
}
}
_state.update {
it.copy(
selectableFiles = editedList,
selectedItemsCount = editedList.filter { file ->
file.isSelected && !file.isDirectory
}.size,
hasSelectedItems = editedList.any { file ->
file.isSelected && !file.isDirectory
}
)
}
}
@ -262,6 +342,7 @@ class BrowseViewModel @Inject constructor(
_state.update {
it.copy(
searchQuery = "",
hasSearched = false,
hasFocused = false
)
}
@ -289,7 +370,11 @@ class BrowseViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
selectableFiles = it.selectableFiles.map { file -> file.copy(second = false) },
selectableFiles = it.selectableFiles.map { file ->
file.copy(
isSelected = false
)
},
hasSelectedItems = false
)
}
@ -302,8 +387,8 @@ class BrowseViewModel @Inject constructor(
searchQuery = event.query
)
}
job?.cancel()
job = viewModelScope.launch(Dispatchers.IO) {
searchQueryJob?.cancel()
searchQueryJob = viewModelScope.launch(Dispatchers.IO) {
delay(500)
yield()
onEvent(BrowseEvent.OnSearch)
@ -322,7 +407,7 @@ class BrowseViewModel @Inject constructor(
showAddingDialog = false
)
}
job3?.cancel(null)
getBooksJob?.cancel()
}
is BrowseEvent.OnAddingDialogRequest -> {
@ -336,7 +421,7 @@ class BrowseViewModel @Inject constructor(
}
is BrowseEvent.OnGetBooksFromFiles -> {
job3 = viewModelScope.launch(Dispatchers.IO) {
getBooksJob = viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isBooksLoading = true
@ -347,15 +432,11 @@ class BrowseViewModel @Inject constructor(
val books = mutableListOf<NullableBook>()
_state.value.selectableFiles
.filter { it.second }
.map { it.first }
.filter { it.isSelected && !it.isDirectory }
.map { it.fileOrDirectory }
.forEach {
yield()
books.add(
getBooksFromFiles.execute(
listOf(it)
).first()
)
books.add(getBookFromFile.execute(it))
}
yield()
@ -422,7 +503,8 @@ class BrowseViewModel @Inject constructor(
it.copy(
isLoading = true,
hasSelectedItems = false,
listState = LazyListState(0, 0)
listState = LazyListState(),
gridState = LazyGridState()
)
}
getFilesFromDownloads()
@ -435,29 +517,201 @@ class BrowseViewModel @Inject constructor(
listState = LazyListState(
it.listState.firstVisibleItemIndex,
0
),
gridState = LazyGridState(
it.gridState.firstVisibleItemIndex,
0
)
)
}
}
is BrowseEvent.OnChangeDirectory -> {
viewModelScope.launch {
changeDirectoryJob?.cancel()
searchQueryJob?.cancel()
changeDirectoryJob = launch(Dispatchers.IO) {
yield()
_state.update {
it.copy(
listState = LazyListState(),
gridState = LazyGridState(),
selectedDirectory = event.directory,
previousDirectory = if (event.savePreviousDirectory) it.selectedDirectory
else event.directory.parentFile,
inNestedDirectory = event.directory != Environment.getExternalStorageDirectory()
)
}
}
}
}
is BrowseEvent.OnGoBackDirectory -> {
onEvent(
BrowseEvent.OnChangeDirectory(
_state.value.previousDirectory
?: Environment.getExternalStorageDirectory(),
savePreviousDirectory = false
)
)
}
is BrowseEvent.OnShowHideFilterBottomSheet -> {
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
showFilterBottomSheet = !it.showFilterBottomSheet
)
}
}
}
is BrowseEvent.OnScrollToFilterPage -> {
viewModelScope.launch {
_state.update {
event.pagerState?.scrollToPage(event.page)
it.copy(
currentPage = event.page
)
}
}
}
is BrowseEvent.OnUpdateFavoriteDirectory -> {
viewModelScope.launch {
_state.update {
it.copy(isLoading = true)
}
updateFavoriteDirectory.execute(event.path)
getFilesFromDownloads()
}
}
}
}
fun filterList(mainState: MainState): List<SelectableFile> {
fun <T> thenCompareBy(
selector: (T) -> Comparable<*>?
): Comparator<T> {
return if (mainState.browseSortOrderDescending!!) {
compareByDescending(selector)
} else {
compareBy(selector)
}
}
fun List<SelectableFile>.filterFiles(): List<SelectableFile> {
if (mainState.browseIncludedFilterItems!!.isEmpty()) {
return this
}
return filter { file ->
when (file.isDirectory) {
true -> {
return@filter this.filter {
if (file == it) {
return@filter false
}
it.fileOrDirectory.path.startsWith(file.fileOrDirectory.path)
}.filterFiles().isNotEmpty()
}
false -> {
return@filter mainState.browseIncludedFilterItems.any {
file.fileOrDirectory.path.endsWith(
it, ignoreCase = true
)
}
}
}
}
}
return _state.value.selectableFiles
.filterFiles()
.filter {
if (
_state.value.hasSearched
|| mainState.browseFilesStructure == BrowseFilesStructure.ALL_FILES
) {
return@filter !it.isDirectory
}
if (
Environment.getExternalStorageDirectory() == _state.value.selectedDirectory
&& it.isFavorite
&& mainState.browsePinFavoriteDirectories!!
) {
return@filter true
}
it.parentDirectory == _state.value.selectedDirectory
}
.sortedWith(
compareByDescending<SelectableFile> {
when (mainState.browsePinFavoriteDirectories!!) {
true -> it.isFavorite
false -> true
}
}.then(
compareByDescending {
when (mainState.browseSortOrder!! != BrowseSortOrder.FILE_TYPE) {
true -> it.isDirectory
false -> true
}
}
).then(
thenCompareBy {
when (mainState.browseSortOrder!!) {
BrowseSortOrder.NAME -> {
it.fileOrDirectory.name.lowercase().trim()
}
BrowseSortOrder.FILE_TYPE -> {
it.isDirectory
}
BrowseSortOrder.FILE_FORMAT -> {
it.fileOrDirectory.extension
}
BrowseSortOrder.FILE_SIZE -> {
it.fileOrDirectory.length()
}
BrowseSortOrder.LAST_MODIFIED -> {
it.fileOrDirectory.lastModified()
}
}
}
)
)
}
private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
) {
getFilesFromDevice.execute(query).collect { result ->
when (result) {
is Resource.Success -> {
getFilesFromDevice.execute(query).apply {
yield()
_state.update {
it.copy(
selectableFiles = result.data?.map { book -> Pair(book, false) }
?: emptyList(),
selectableFiles = this,
selectedItemsCount = 0,
hasSearched = query.isNotBlank(),
hasSelectedItems = false,
isLoading = false
)
}
}
}
is Resource.Error -> Unit
fun clearViewModel() {
viewModelScope.launch(Dispatchers.Main) {
_state.update {
it.copy(isError = false)
}
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
@ -113,6 +114,18 @@ private fun SettingsScreen(
}
}
item {
SettingsCategoryItem(
icon = Icons.Default.Explore,
text = stringResource(id = R.string.browse_settings),
description = stringResource(id = R.string.browse_settings_desc)
) {
onNavigate {
navigate(Screen.Settings.BrowseSettings)
}
}
}
item { Spacer(modifier = Modifier.height(48.dp)) }
}
}

View file

@ -0,0 +1,91 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.BrowseSettingsCategory
@Composable
fun BrowseSettingsRoot() {
val navigator = LocalNavigator.current
val mainViewModel: MainViewModel = hiltViewModel()
val state = mainViewModel.state.collectAsState()
BrowseSettings(
state = state,
onNavigate = { navigator.it() },
onMainEvent = mainViewModel::onEvent
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BrowseSettings(
state: State<MainState>,
onNavigate: OnNavigate,
onMainEvent: (MainEvent) -> Unit
) {
val scrollState = TopAppBarDefaults.collapsibleUntilExitScrollBehaviorWithLazyListState()
Scaffold(
Modifier
.fillMaxSize()
.nestedScroll(scrollState.first.nestedScrollConnection)
.windowInsetsPadding(WindowInsets.navigationBars)
.imePadding(),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
LargeTopAppBar(
title = {
Text(stringResource(id = R.string.browse_settings))
},
navigationIcon = {
GoBackButton(onNavigate = onNavigate)
},
scrollBehavior = scrollState.first,
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer
)
)
}
) { paddingValues ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding()),
state = scrollState.second
) {
BrowseSettingsCategory(
state = state,
onMainEvent = onMainEvent
)
}
}
}

View file

@ -0,0 +1,48 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.State
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseFilterSubcategory
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseGeneralSubcategory
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories.BrowseSortSubcategory
/**
* Browse Settings Category.
* Contains all Browse settings.
*
* @param state [MainState] instance.
* @param onMainEvent [MainEvent] callback.
* @param topPadding Top padding to be applied.
* @param bottomPadding Bottom padding to be applied.
*/
fun LazyListScope.BrowseSettingsCategory(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 48.dp
) {
BrowseGeneralSubcategory(
state = state,
onMainEvent = onMainEvent,
topPadding = topPadding,
bottomPadding = 0.dp
)
BrowseFilterSubcategory(
state = state,
onMainEvent = onMainEvent,
topPadding = 22.dp,
bottomPadding = 0.dp
)
BrowseSortSubcategory(
state = state,
onMainEvent = onMainEvent,
topPadding = 22.dp,
bottomPadding = bottomPadding
)
}

View file

@ -0,0 +1,46 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.ButtonItem
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.components.SegmentedButtonWithTitle
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
/**
* Browse Files Structure setting.
* Lets user choose between all and folders structure for Browse.
*/
@Composable
fun LazyItemScope.BrowseFilesStructureSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
SegmentedButtonWithTitle(
title = stringResource(id = R.string.files_structure_option),
modifier = Modifier.animateItem(),
buttons = BrowseFilesStructure.entries.map {
ButtonItem(
it.toString(),
when (it) {
BrowseFilesStructure.ALL_FILES -> stringResource(id = R.string.files_structure_all)
BrowseFilesStructure.DIRECTORIES -> stringResource(id = R.string.files_structure_directory)
},
MaterialTheme.typography.labelLarge,
it == state.value.browseFilesStructure
)
}
) {
onMainEvent(
MainEvent.OnChangeBrowseFilesStructure(
it.id
)
)
}
}

View file

@ -0,0 +1,86 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
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.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
/**
* Browse Filter setting.
* Lets user choose which items to filter in Browse.
*/
fun LazyListScope.BrowseFilterSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
customItems(Constants.EXTENSIONS, key = { it }) {
FilterItem(
item = it,
isSelected = state.value.browseIncludedFilterItems!!.any { item ->
item == it
}
) {
onMainEvent(
MainEvent.OnChangeBrowseIncludedFilterItem(it)
)
}
}
}
/**
* Filter Item.
* Can be selected.
*
* @param item Item.
* @param isSelected Whether item is selected.
* @param onClick OnClick callback.
*/
@Composable
private fun LazyItemScope.FilterItem(
item: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.animateItem()
.fillMaxWidth()
.clickable {
onClick()
}
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = { onClick() }
)
Spacer(modifier = Modifier.width(14.dp))
Text(
text = item,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,48 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.components.SliderWithTitle
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
import ua.acclorite.book_story.presentation.ui.SlidingTransition
/**
* Browse Grid Size setting.
* Lets user specify grid size to be displayed.
*/
@Composable
fun LazyItemScope.BrowseGridSizeSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
SlidingTransition(
modifier = Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
),
visible = state.value.browseLayout == BrowseLayout.GRID,
) {
SliderWithTitle(
value = state.value.browseGridSize!!
to " ${stringResource(R.string.browse_grid_size_per_row)}",
valuePlaceholder = stringResource(id = R.string.browse_grid_size_auto),
showPlaceholder = state.value.browseAutoGridSize!!,
modifier = Modifier.animateItem(),
fromValue = 0,
toValue = 10,
title = stringResource(id = R.string.browse_grid_size_option),
onValueChange = {
onMainEvent(MainEvent.OnChangeBrowseAutoGridSize(it == 0))
onMainEvent(
MainEvent.OnChangeBrowseGridSize(it)
)
}
)
}
}

View file

@ -0,0 +1,46 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.ButtonItem
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.components.SegmentedButtonWithTitle
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout
/**
* Browse Layout setting.
* Lets user choose between layouts(list, grid).
*/
@Composable
fun LazyItemScope.BrowseLayoutSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
SegmentedButtonWithTitle(
title = stringResource(id = R.string.browse_layout_option),
modifier = Modifier.animateItem(),
buttons = BrowseLayout.entries.map {
ButtonItem(
it.toString(),
when (it) {
BrowseLayout.LIST -> stringResource(id = R.string.browse_layout_list)
BrowseLayout.GRID -> stringResource(id = R.string.browse_layout_grid)
},
MaterialTheme.typography.labelLarge,
it == state.value.browseLayout
)
}
) {
onMainEvent(
MainEvent.OnChangeBrowseLayout(
it.id
)
)
}
}

View file

@ -0,0 +1,43 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWithTitle
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.ui.SlidingTransition
/**
* Browse Pin Favorite Directories setting.
* If true, shows all favorite directories in root directory and moves them to the top in nested directories.
*/
@Composable
fun LazyItemScope.BrowsePinFavoriteDirectoriesSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
SlidingTransition(
modifier = Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
),
visible = state.value.browseFilesStructure == BrowseFilesStructure.DIRECTORIES
) {
SwitchWithTitle(
selected = state.value.browsePinFavoriteDirectories!!,
title = stringResource(id = R.string.browse_pin_favorite_directories_option),
description = stringResource(id = R.string.browse_pin_favorite_directories_option_desc)
) {
onMainEvent(
MainEvent.OnChangeBrowsePinFavoriteDirectories(
!state.value.browsePinFavoriteDirectories!!
)
)
}
}
}

View file

@ -0,0 +1,114 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings
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.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseSortOrder
/**
* Browse Sort Order setting.
* Lets user choose how to sort files in Browse.
*/
fun LazyListScope.BrowseSortOrderSetting(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit
) {
customItems(BrowseSortOrder.entries, key = { it.name }) {
SortItem(
item = it,
isSelected = state.value.browseSortOrder!! == it,
isDescending = state.value.browseSortOrderDescending!!
) {
if (state.value.browseSortOrder!! == it) {
onMainEvent(
MainEvent.OnChangeBrowseSortOrderDescending(
!state.value.browseSortOrderDescending!!
)
)
} else {
onMainEvent(MainEvent.OnChangeBrowseSortOrderDescending(true))
onMainEvent(MainEvent.OnChangeBrowseSortOrder(it.name))
}
}
}
}
/**
* Sort Item.
* Can sort items by descending or ascending.
*
* @param item [BrowseSortOrder].
* @param isSelected Whether items are currently sorted by this item.
* @param isDescending Whether sorted by descending.
* @param onClick OnClick callback.
*/
@Composable
private fun LazyItemScope.SortItem(
item: BrowseSortOrder,
isSelected: Boolean,
isDescending: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.animateItem()
.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.primary
else Color.Transparent
)
Spacer(modifier = Modifier.width(24.dp))
Text(
text = stringResource(
when (item) {
BrowseSortOrder.NAME -> R.string.browse_sort_order_name
BrowseSortOrder.FILE_FORMAT -> R.string.browse_sort_order_file_format
BrowseSortOrder.FILE_TYPE -> R.string.browse_sort_order_file_type
BrowseSortOrder.LAST_MODIFIED -> R.string.browse_sort_order_last_modified
BrowseSortOrder.FILE_SIZE -> R.string.browse_sort_order_file_size
}
),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,70 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowseFilterSetting
/**
* Browse Filter subcategory.
* Contains all settings from Filter Browse settings.
*/
fun LazyListScope.BrowseFilterSubcategory(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit,
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.filter_browse_settings) },
showTitle: Boolean = true,
topPadding: Dp,
bottomPadding: Dp
) {
item {
if (showTitle) {
CategoryTitle(
title = title.invoke(),
color = titleColor.invoke(),
modifier = Modifier
.animateItem()
.padding(
top = topPadding,
bottom = 8.dp
)
)
} else {
Spacer(
modifier = Modifier
.animateItem()
.height((topPadding - 8.dp).coerceAtLeast(0.dp))
)
}
}
BrowseFilterSetting(
state = state,
onMainEvent = onMainEvent
)
item {
Spacer(
modifier = Modifier
.animateItem()
.height(bottomPadding)
)
}
}

View file

@ -0,0 +1,96 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowseFilesStructureSetting
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowseGridSizeSetting
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowseLayoutSetting
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowsePinFavoriteDirectoriesSetting
/**
* Browse General subcategory.
* Contains all settings from General Browse settings.
*/
fun LazyListScope.BrowseGeneralSubcategory(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit,
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.general_browse_settings) },
showTitle: Boolean = true,
topPadding: Dp,
bottomPadding: Dp
) {
item {
if (showTitle) {
CategoryTitle(
title = title.invoke(),
color = titleColor.invoke(),
modifier = Modifier
.animateItem()
.padding(
top = topPadding,
bottom = 8.dp
)
)
} else {
Spacer(
modifier = Modifier
.animateItem()
.height((topPadding - 8.dp).coerceAtLeast(0.dp))
)
}
}
item {
BrowseFilesStructureSetting(
state = state,
onMainEvent = onMainEvent
)
}
item {
BrowsePinFavoriteDirectoriesSetting(
state = state,
onMainEvent = onMainEvent
)
}
item {
BrowseLayoutSetting(
state = state,
onMainEvent = onMainEvent
)
}
item {
BrowseGridSizeSetting(
state = state,
onMainEvent = onMainEvent
)
}
item {
Spacer(
modifier = Modifier
.animateItem()
.height(bottomPadding)
)
}
}

View file

@ -0,0 +1,70 @@
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.subcategories
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.components.settings.BrowseSortOrderSetting
/**
* Browse Sort subcategory.
* Contains all settings from Sort Browse settings.
*/
fun LazyListScope.BrowseSortSubcategory(
state: State<MainState>,
onMainEvent: (MainEvent) -> Unit,
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.sort_browse_settings) },
showTitle: Boolean = true,
topPadding: Dp,
bottomPadding: Dp
) {
item {
if (showTitle) {
CategoryTitle(
title = title.invoke(),
color = titleColor.invoke(),
modifier = Modifier
.animateItem()
.padding(
top = topPadding,
bottom = 8.dp
)
)
} else {
Spacer(
modifier = Modifier
.animateItem()
.height((topPadding - 8.dp).coerceAtLeast(0.dp))
)
}
}
BrowseSortOrderSetting(
state = state,
onMainEvent = onMainEvent
)
item {
Spacer(
modifier = Modifier
.animateItem()
.height(bottomPadding)
)
}
}

View file

@ -0,0 +1,12 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.data
import androidx.compose.runtime.Immutable
@Immutable
enum class BrowseFilesStructure {
ALL_FILES, DIRECTORIES
}
fun String.toFilesStructure(): BrowseFilesStructure {
return BrowseFilesStructure.valueOf(this)
}

View file

@ -0,0 +1,9 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.data
enum class BrowseLayout {
LIST, GRID
}
fun String.toBrowseLayout(): BrowseLayout {
return BrowseLayout.valueOf(this)
}

View file

@ -0,0 +1,13 @@
package ua.acclorite.book_story.presentation.screens.settings.nested.browse.data
enum class BrowseSortOrder {
NAME,
FILE_FORMAT,
FILE_TYPE,
LAST_MODIFIED,
FILE_SIZE,
}
fun String.toBrowseSortOrder(): BrowseSortOrder {
return BrowseSortOrder.valueOf(this)
}

View file

@ -187,6 +187,8 @@
<string name="dropped_tab">Кинуто</string>
<string name="general_tab">Головні</string>
<string name="color_tab">Кольори</string>
<string name="filter_tab">Фільтр</string>
<string name="sort_tab">Сортування</string>
<!-- Settings categories -->
<string name="general_settings">Головні</string>
@ -195,6 +197,8 @@
<string name="appearance_settings_desc">Тема, кольори</string>
<string name="reader_settings">Читач</string>
<string name="reader_settings_desc">Шрифт, текст, перекладач</string>
<string name="browse_settings">Огляд</string>
<string name="browse_settings_desc">Головні, фільтр, сортування</string>
<!-- Settings subcategories -->
<string name="font_reader_settings">Шрифт</string>
@ -202,6 +206,9 @@
<string name="translator_reader_settings">Перекладач</string>
<string name="theme_appearance_settings">Налаштування теми</string>
<string name="colors_appearance_settings">Кольори</string>
<string name="general_browse_settings">Головні</string>
<string name="filter_browse_settings">Фільтр</string>
<string name="sort_browse_settings">Сотрування</string>
<!-- Settings options -->
<string name="language_option">Мова застосунка</string>
@ -225,6 +232,13 @@
<string name="double_click_translation_option_desc">Двічі клацніть на абзац, щоб перекласти його</string>
<string name="fast_color_preset_change_option">Швидка зміна пресета кольорів</string>
<string name="fast_color_preset_change_option_desc">Проведіть вліво або вправо, щоб змінити пресет кольорів</string>
<string name="files_structure_option">Структура файлів</string>
<string name="browse_layout_option">Режим відображення</string>
<string name="browse_grid_size_option">Кількість комірок</string>
<string name="browse_pin_favorite_directories_option">Закріпити улюблені директорії</string>
<string name="browse_pin_favorite_directories_option_desc">
Закріпіть ваші улюблені директорії зверху екрана
</string>
<!-- Color presets -->
<string name="red_color">Червоний</string>
@ -251,6 +265,25 @@
<string name="font_style_italic">Похилий</string>
<string name="font_style_normal">Нормальний</string>
<!-- Files Structure properties -->
<string name="files_structure_all">Всі файли</string>
<string name="files_structure_directory">Директорії</string>
<!-- Browse Layout properties -->
<string name="browse_layout_list">Список</string>
<string name="browse_layout_grid">Сітка</string>
<!-- Browse Grid Size properties -->
<string name="browse_grid_size_auto">Авто</string>
<string name="browse_grid_size_per_row">кожен рядок</string>
<!-- Browse Sort Order properties -->
<string name="browse_sort_order_name">За алфавітом</string>
<string name="browse_sort_order_file_format">Формат файла</string>
<string name="browse_sort_order_file_type">Тип файла</string>
<string name="browse_sort_order_last_modified">Остання зміна</string>
<string name="browse_sort_order_file_size">Розмір файла</string>
<!-- Themes -->
<string name="dynamic_theme">Меркурій</string>
<string name="blue_theme">Нептун</string>
@ -293,6 +326,9 @@
<string name="file_size">Розмір файлу</string>
<string name="unknown">Невідомо</string>
<!-- Browse -->
<string name="internal_storage">Внутрішнє Сховище</string>
<!-- Reader -->
<string name="loading">Завантаження…</string>
<string name="happy_reading">Приємного читання!</string>
@ -512,6 +548,10 @@
<string name="open_reader_settings_content_desc">Налаштування читача</string>
<string name="top_app_bar_anim_content_desc">Анімація верхньої панелі</string>
<string name="file_icon_content_desc">Файл</string>
<string name="directory_icon_content_desc">Тека</string>
<string name="favorite_directory_content_desc">Улюблена тека</string>
<string name="sort_order_content_desc">Порядок сортування</string>
<string name="path_arrow_icon_content_desc">Стрілка шляху</string>
<string name="checkbox_content_desc">Галочка</string>
<string name="show_dropdown_content_desc">Випадаючий список</string>
<string name="cover_image_not_found_content_desc">Обкладенка не знайдена</string>
@ -520,6 +560,7 @@
<string name="search_content_desc">Пошук</string>
<string name="clear_selected_items_content_desc">Очистити обрані предмети</string>
<string name="add_files_content_desc">Додати файли</string>
<string name="select_all_files_content_desc">Вибрати всі файли</string>
<string name="exit_search_content_desc">Вийти з пошуку</string>
<string name="delete_whole_history_content_desc">Видалити всю історію</string>
<string name="move_books_content_desc">Перемістити обрані книги</string>
@ -545,5 +586,6 @@
<string name="github_project_content_desc">GitHub Проєкт</string>
<string name="github_profile_content_desc">GitHub Профіль</string>
<string name="drag_content_desc">Тягнути</string>
<string name="filter_content_desc">Фільтр</string>
</resources>

View file

@ -206,6 +206,8 @@
<string name="dropped_tab">Dropped</string>
<string name="general_tab">General</string>
<string name="color_tab">Colors</string>
<string name="filter_tab">Filter</string>
<string name="sort_tab">Sort</string>
<!-- Settings categories -->
<string name="general_settings">General</string>
@ -214,6 +216,8 @@
<string name="appearance_settings_desc">Theme, colors</string>
<string name="reader_settings">Reader</string>
<string name="reader_settings_desc">Font, text, translator</string>
<string name="browse_settings">Browse</string>
<string name="browse_settings_desc">General, filter, sort</string>
<!-- Settings subcategories -->
<string name="font_reader_settings">Font</string>
@ -221,6 +225,9 @@
<string name="translator_reader_settings">Translator</string>
<string name="theme_appearance_settings">Theme preferences</string>
<string name="colors_appearance_settings">Colors</string>
<string name="general_browse_settings">General</string>
<string name="filter_browse_settings">Filter</string>
<string name="sort_browse_settings">Sort</string>
<!-- Settings options -->
<string name="language_option">App language</string>
@ -244,6 +251,13 @@
<string name="double_click_translation_option_desc">Double click on paragraph to translate it</string>
<string name="fast_color_preset_change_option">Fast color preset change</string>
<string name="fast_color_preset_change_option_desc">Swipe left or right to change color preset</string>
<string name="files_structure_option">Files structure</string>
<string name="browse_layout_option">Display mode</string>
<string name="browse_grid_size_option">Grid size</string>
<string name="browse_pin_favorite_directories_option">Pin favorite directories</string>
<string name="browse_pin_favorite_directories_option_desc">
Pin your favorite directories to the top of the screen
</string>
<!-- Color presets -->
<string name="red_color">Red</string>
@ -270,6 +284,25 @@
<string name="font_style_italic">Italic</string>
<string name="font_style_normal">Normal</string>
<!-- Files Structure properties -->
<string name="files_structure_all">All files</string>
<string name="files_structure_directory">Directories</string>
<!-- Browse Layout properties -->
<string name="browse_layout_list">List</string>
<string name="browse_layout_grid">Grid</string>
<!-- Browse Grid Size properties -->
<string name="browse_grid_size_auto">Auto</string>
<string name="browse_grid_size_per_row">per row</string>
<!-- Browse Sort Order properties -->
<string name="browse_sort_order_name">Alphabetically</string>
<string name="browse_sort_order_file_format">File format</string>
<string name="browse_sort_order_file_type">File type</string>
<string name="browse_sort_order_last_modified">Last modified</string>
<string name="browse_sort_order_file_size">File size</string>
<!-- Themes -->
<string name="dynamic_theme">Mercury</string>
<string name="blue_theme">Neptune</string>
@ -312,6 +345,9 @@
<string name="file_size">File size</string>
<string name="unknown">Unknown</string>
<!-- Browse -->
<string name="internal_storage">Internal Storage</string>
<!-- Reader -->
<string name="loading">Loading…</string>
<string name="happy_reading">Happy reading!</string>
@ -530,6 +566,10 @@
<string name="open_reader_settings_content_desc">Reader settings</string>
<string name="top_app_bar_anim_content_desc">Top app bar animation</string>
<string name="file_icon_content_desc">File</string>
<string name="directory_icon_content_desc">Directory</string>
<string name="favorite_directory_content_desc">Favorite directory</string>
<string name="sort_order_content_desc">Sort order</string>
<string name="path_arrow_icon_content_desc">Path arrow</string>
<string name="checkbox_content_desc">Checkbox</string>
<string name="show_dropdown_content_desc">Dropdown</string>
<string name="cover_image_not_found_content_desc">Cover image not found</string>
@ -538,6 +578,7 @@
<string name="search_content_desc">Search</string>
<string name="clear_selected_items_content_desc">Clear selected items</string>
<string name="add_files_content_desc">Add files</string>
<string name="select_all_files_content_desc">Select all files</string>
<string name="exit_search_content_desc">Exit search</string>
<string name="delete_whole_history_content_desc">Delete whole history</string>
<string name="move_books_content_desc">Move selected books</string>
@ -565,5 +606,6 @@
<string name="github_project_content_desc">GitHub Project</string>
<string name="github_profile_content_desc">GitHub Profile</string>
<string name="drag_content_desc">Drag</string>
<string name="filter_content_desc">Filter</string>
</resources>