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

View file

@ -44,6 +44,7 @@ object AppModule {
.addMigrations( .addMigrations(
DatabaseHelper.MIGRATION_2_3, // creates LanguageHistoryEntity table(if does not exist) DatabaseHelper.MIGRATION_2_3, // creates LanguageHistoryEntity table(if does not exist)
DatabaseHelper.MIGRATION_4_5, // creates ColorPresetEntity 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() .allowMainThreadQueries()
.build() .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 androidx.room.Upsert
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity 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 import ua.acclorite.book_story.data.local.dto.HistoryEntity
@Dao @Dao
@ -78,4 +79,14 @@ interface BookDao {
@Query("DELETE FROM colorpresetentity") @Query("DELETE FROM colorpresetentity")
suspend fun deleteColorPresets() 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 androidx.sqlite.db.SupportSQLiteDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.ColorPresetEntity 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 import ua.acclorite.book_story.data.local.dto.HistoryEntity
@Database( @Database(
@ -17,13 +18,15 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity
BookEntity::class, BookEntity::class,
HistoryEntity::class, HistoryEntity::class,
ColorPresetEntity::class, ColorPresetEntity::class,
FavoriteDirectoryEntity::class,
], ],
version = 5, version = 6,
autoMigrations = [ autoMigrations = [
AutoMigration(1, 2), AutoMigration(1, 2),
AutoMigration(2, 3), AutoMigration(2, 3),
AutoMigration(3, 4, spec = DatabaseHelper.MIGRATION_3_4::class), AutoMigration(3, 4, spec = DatabaseHelper.MIGRATION_3_4::class),
AutoMigration(4, 5), AutoMigration(4, 5),
AutoMigration(5, 6),
], ],
exportSchema = true 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 kotlinx.coroutines.withContext
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.data_store.DataStore 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.notification.UpdatesNotificationService
import ua.acclorite.book_story.data.local.room.BookDao import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.book.BookMapper 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.ColorPreset
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook 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.repository.BookRepository
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.CoverImage
@ -464,19 +466,82 @@ class BookRepositoryImpl @Inject constructor(
return result.await() return result.await()
} }
override suspend fun getFilesFromDevice(query: String): Flow<Resource<List<File>>> { override suspend fun getFilesFromDevice(query: String): List<SelectableFile> {
fun getAllFilesInDirectory(directory: File): List<File> { val existingBooks = database
val filesList = mutableListOf<File>() .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) { if (files != null) {
for (file in files) { for (file in files) {
if (file.isFile) { if (!file.exists()) {
filesList.add(file) continue
} }
if (file.isDirectory) {
val subDirectoryFiles = getAllFilesInDirectory(file) when {
filesList.addAll(subDirectoryFiles) 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)
}
}
} }
} }
} }
@ -484,77 +549,17 @@ class BookRepositoryImpl @Inject constructor(
return filesList return filesList
} }
return flow { val rootDirectory = Environment.getExternalStorageDirectory()
val existingBooks = database if (
.searchBooks("") !rootDirectory.exists() ||
.map { bookMapper.toBook(it) } !rootDirectory.isDirectory ||
(Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED &&
val primaryDirectory = Environment.getExternalStorageDirectory() Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED_READ_ONLY)
) {
if ( return emptyList()
!primaryDirectory.exists() ||
(Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED &&
Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED_READ_ONLY)
) {
emit(Resource.Success(null))
return@flow
}
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> { 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.ColorPreset
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook 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.CoverImage
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
@ -71,7 +72,7 @@ interface BookRepository {
suspend fun getAllSettings(scope: CoroutineScope): MainState 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> suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
@ -103,4 +104,7 @@ interface BookRepository {
suspend fun reorderColorPresets(orderedColorPresets: List<ColorPreset>) suspend fun reorderColorPresets(orderedColorPresets: List<ColorPreset>)
suspend fun deleteColorPreset(colorPreset: 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 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.repository.BookRepository
import ua.acclorite.book_story.domain.util.Resource
import java.io.File
import javax.inject.Inject import javax.inject.Inject
class GetFilesFromDevice @Inject constructor( class GetFilesFromDevice @Inject constructor(
private val repository: BookRepository private val repository: BookRepository
) { ) {
suspend fun execute(query: String): Flow<Resource<List<File>>> { suspend fun execute(query: String): List<SelectableFile> {
return repository.getFilesFromDevice(query) 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" const val MAIN_STATE = "main_state"
// Supported file extensions // Supported file extensions
val EXTENSIONS = listOf(".txt", ".pdf", ".epub", ".fb2") val EXTENSIONS = listOf(".epub", ".pdf", ".fb2", ".txt")
// Supported languages // Supported languages
val LANGUAGES = listOf( 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.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
object DataStoreConstants { object DataStoreConstants {
// App settings // General settings
val LANGUAGE = stringPreferencesKey("language") val LANGUAGE = stringPreferencesKey("language")
val THEME = stringPreferencesKey("theme") val THEME = stringPreferencesKey("theme")
val DARK_THEME = stringPreferencesKey("dark_theme") val DARK_THEME = stringPreferencesKey("dark_theme")
val PURE_DARK = stringPreferencesKey("pure_dark") val PURE_DARK = stringPreferencesKey("pure_dark")
val THEME_CONTRAST = stringPreferencesKey("theme_contrast") val THEME_CONTRAST = stringPreferencesKey("theme_contrast")
val SHOW_START_SCREEN = booleanPreferencesKey("guide") 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 FONT = stringPreferencesKey("font")
val IS_ITALIC = booleanPreferencesKey("font_style") val IS_ITALIC = booleanPreferencesKey("font_style")
val FONT_SIZE = intPreferencesKey("font_size") val FONT_SIZE = intPreferencesKey("font_size")
val LINE_HEIGHT = intPreferencesKey("line_height") val LINE_HEIGHT = intPreferencesKey("line_height")
val PARAGRAPH_HEIGHT = intPreferencesKey("paragraph_height") val PARAGRAPH_HEIGHT = intPreferencesKey("paragraph_height")
val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation") val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation")
val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates")
val SIDE_PADDING = intPreferencesKey("side_padding") // Browse settings
val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation") val BROWSE_FILES_STRUCTURE = stringPreferencesKey("browse_files_structure")
val FAST_COLOR_PRESET_CHANGE = booleanPreferencesKey("fast_color_preset_change") 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 package ua.acclorite.book_story.presentation.data
import androidx.compose.runtime.Immutable
@Immutable
sealed class MainEvent { sealed class MainEvent {
data class OnChangeLanguage(val lang: String) : MainEvent() data class OnChangeLanguage(val lang: String) : MainEvent()
data class OnChangeTheme(val theme: 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 OnChangeSidePadding(val sidePadding: Int) : MainEvent()
data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent() data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent()
data class OnChangeFastColorPresetChange(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 package ua.acclorite.book_story.presentation.data
import android.os.Build import android.os.Build
@ -7,6 +9,12 @@ import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.DataStoreConstants 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.DarkTheme
import ua.acclorite.book_story.presentation.ui.PureDark import ua.acclorite.book_story.presentation.ui.PureDark
import ua.acclorite.book_story.presentation.ui.Theme import ua.acclorite.book_story.presentation.ui.Theme
@ -42,6 +50,14 @@ data class MainState(
val sidePadding: Int? = null, val sidePadding: Int? = null,
val doubleClickTranslation: Boolean? = null, val doubleClickTranslation: Boolean? = null,
val fastColorPresetChange: 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 { ) : Parcelable {
companion object { companion object {
/** /**
@ -105,6 +121,37 @@ data class MainState(
data[FAST_COLOR_PRESET_CHANGE.name] as? Boolean data[FAST_COLOR_PRESET_CHANGE.name] as? Boolean
?: true ?: 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( return MainState(
language = language, language = language,
theme = theme.toTheme(), theme = theme.toTheme(),
@ -121,7 +168,15 @@ data class MainState(
checkForUpdates = checkForUpdates, checkForUpdates = checkForUpdates,
sidePadding = sidePadding, sidePadding = sidePadding,
doubleClickTranslation = doubleClickTranslation, 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.domain.util.DataStoreConstants
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel 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.toDarkTheme
import ua.acclorite.book_story.presentation.ui.toPureDark import ua.acclorite.book_story.presentation.ui.toPureDark
import ua.acclorite.book_story.presentation.ui.toTheme 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 @Parcelize
@Keep @Keep
data object ReaderSettings : Screen() data object ReaderSettings : Screen()
@Parcelize
@Keep
data object BrowseSettings : Screen()
} }
@Parcelize @Parcelize

View file

@ -3,126 +3,125 @@ package ua.acclorite.book_story.presentation.screens.browse
import android.Manifest import android.Manifest
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler 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.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi 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.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import ua.acclorite.book_story.R 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.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.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.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.BrowseStoragePermissionDialog
import ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog.BrowseAddingDialog 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.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState 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.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure
import ua.acclorite.book_story.presentation.ui.DefaultTransition import ua.acclorite.book_story.presentation.ui.DefaultTransition
import ua.acclorite.book_story.presentation.ui.Transitions
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun BrowseScreenRoot() { fun BrowseScreenRoot() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel: BrowseViewModel = hiltViewModel() val viewModel: BrowseViewModel = hiltViewModel()
val libraryViewModel: LibraryViewModel = hiltViewModel() val libraryViewModel: LibraryViewModel = hiltViewModel()
val mainViewModel: MainViewModel = hiltViewModel()
val state = viewModel.state.collectAsState() val state = viewModel.state.collectAsState()
val mainState = mainViewModel.state.collectAsState()
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( BrowseScreen(
state = state, state = state,
mainState = mainState,
permissionState = permissionState,
filteredFiles = filteredFiles.value,
onNavigate = { navigator.it() }, onNavigate = { navigator.it() },
onEvent = viewModel::onEvent, onEvent = viewModel::onEvent,
onMainEvent = mainViewModel::onEvent,
onLibraryEvent = libraryViewModel::onEvent onLibraryEvent = libraryViewModel::onEvent
) )
DisposableEffect(Unit) {
onDispose {
viewModel.clearViewModel()
}
}
} }
@OptIn( @OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalMaterialApi::class ExperimentalMaterialApi::class
) )
@Composable @Composable
private fun BrowseScreen( private fun BrowseScreen(
state: State<BrowseState>, state: State<BrowseState>,
mainState: State<MainState>,
permissionState: PermissionState,
filteredFiles: List<SelectableFile>,
onNavigate: OnNavigate, onNavigate: OnNavigate,
onEvent: (BrowseEvent) -> Unit, onEvent: (BrowseEvent) -> Unit,
onMainEvent: (MainEvent) -> Unit,
onLibraryEvent: (LibraryEvent) -> Unit onLibraryEvent: (LibraryEvent) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val permissionState = rememberPermissionState(
permission = Manifest.permission.READ_EXTERNAL_STORAGE
)
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.value.isRefreshing, refreshing = state.value.isRefreshing,
onRefresh = { onRefresh = {
onEvent(BrowseEvent.OnRefreshList) 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) { if (state.value.requestPermissionDialog) {
BrowseStoragePermissionDialog(onEvent, permissionState) { BrowseStoragePermissionDialog(onEvent, permissionState)
showErrorMessage = it
}
} }
if (state.value.showAddingDialog) { if (state.value.showAddingDialog) {
BrowseAddingDialog( BrowseAddingDialog(
@ -131,6 +130,14 @@ private fun BrowseScreen(
onLibraryEvent = onLibraryEvent onLibraryEvent = onLibraryEvent
) )
} }
if (state.value.showFilterBottomSheet) {
BrowseFilterBottomSheet(
state = state,
mainState = mainState,
onMainEvent = onMainEvent,
onEvent = onEvent
)
}
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@ -138,90 +145,11 @@ private fun BrowseScreen(
.pullRefresh(refreshState), .pullRefresh(refreshState),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
AnimatedTopAppBar( BrowseTopBar(
scrollBehavior = null, state = state,
isTopBarScrolled = state.value.hasSelectedItems || state.value.listState.canScrollBackward, mainState = mainState,
filteredFiles = filteredFiles,
content1Visibility = !state.value.hasSelectedItems && !state.value.showSearch, onEvent = onEvent
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()
}
) )
} }
) { padding -> ) { padding ->
@ -231,86 +159,81 @@ private fun BrowseScreen(
.padding(top = padding.calculateTopPadding()) .padding(top = padding.calculateTopPadding())
) { ) {
DefaultTransition(visible = !state.value.isLoading) { DefaultTransition(visible = !state.value.isLoading) {
LazyColumn( BrowseLayout(
state = state.value.listState, state = state,
modifier = Modifier mainState = mainState,
.fillMaxSize() filteredFiles = filteredFiles,
) { onLongItemClick = { selectableFile ->
item { when (selectableFile.isDirectory) {
Spacer(modifier = Modifier.height(8.dp)) false -> {
}
customItems(
state.value.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem(
file = selectableFile,
modifier = Modifier.animateItem(),
hasSelectedFiles = state.value.selectableFiles.any { it.second },
onLongClick = {
Toast.makeText( Toast.makeText(
context, context,
context.getString( context.getString(
R.string.file_path_query, R.string.file_path_query,
selectableFile.first.path selectableFile.fileOrDirectory.path
), ),
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
},
onClick = {
onEvent(BrowseEvent.OnSelectFile(selectableFile))
} }
true -> {
onEvent(
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
)
)
}
item { true -> {
Spacer(modifier = Modifier.height(8.dp)) if (!state.value.hasSelectedItems) {
} onEvent(
} BrowseEvent.OnChangeDirectory(
} selectableFile.fileOrDirectory,
savePreviousDirectory = true
CustomAnimatedVisibility( )
visible = showErrorMessage, )
modifier = Modifier.align(Alignment.Center), } else {
enter = Transitions.DefaultTransitionIn, onEvent(
exit = fadeOut(tween(0)) BrowseEvent.OnSelectFile(
) { includedFileFormats = mainState.value.browseIncludedFilterItems!!,
IsError( file = selectableFile
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(
permissionState,
hideErrorMessage = { showErrorMessage = false }
)
)
}
}
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))
} }
} }
) )
} }
BrowseEmptyPlaceholder(
state = state,
isFilesEmpty = filteredFiles.isEmpty(),
storagePermissionState = permissionState,
onNavigate = onNavigate,
onEvent = onEvent
)
PullRefreshIndicator( PullRefreshIndicator(
state.value.isRefreshing, state.value.isRefreshing,
refreshState, refreshState,
@ -332,17 +255,18 @@ private fun BrowseScreen(
return@BackHandler return@BackHandler
} }
if (
state.value.inNestedDirectory
&& mainState.value.browseFilesStructure != BrowseFilesStructure.ALL_FILES
) {
onEvent(
BrowseEvent.OnGoBackDirectory
)
return@BackHandler
}
onNavigate { onNavigate {
navigate(Screen.Library) 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. * Storage permission dialog.
*
* @param onEvent [BrowseEvent] callback.
* @param permissionState Storage [PermissionState].
*/ */
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun BrowseStoragePermissionDialog( fun BrowseStoragePermissionDialog(
onEvent: (BrowseEvent) -> Unit, onEvent: (BrowseEvent) -> Unit,
permissionState: PermissionState, permissionState: PermissionState
showErrorMessage: (Boolean) -> Unit
) { ) {
val activity = LocalContext.current as ComponentActivity val activity = LocalContext.current as ComponentActivity
@ -32,8 +34,7 @@ fun BrowseStoragePermissionDialog(
onDismiss = { onDismiss = {
onEvent( onEvent(
BrowseEvent.OnStoragePermissionDismiss( BrowseEvent.OnStoragePermissionDismiss(
permissionState, permissionState = permissionState
showErrorMessage = { showErrorMessage(true) }
) )
) )
}, },
@ -43,9 +44,8 @@ fun BrowseStoragePermissionDialog(
onAction = { onAction = {
onEvent( onEvent(
BrowseEvent.OnStoragePermissionRequest( BrowseEvent.OnStoragePermissionRequest(
activity, activity = activity,
storagePermissionState = permissionState, storagePermissionState = permissionState
hideErrorMessage = { showErrorMessage(false) }
) )
) )
} }

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 package ua.acclorite.book_story.presentation.screens.browse.data
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.model.NullableBook 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.OnNavigate
import ua.acclorite.book_story.domain.util.Selected import ua.acclorite.book_story.domain.util.Selected
import java.io.File import java.io.File
@ -16,22 +18,28 @@ import java.io.File
sealed class BrowseEvent { sealed class BrowseEvent {
data class OnStoragePermissionRequest( data class OnStoragePermissionRequest(
val activity: ComponentActivity, val activity: ComponentActivity,
val storagePermissionState: PermissionState, val storagePermissionState: PermissionState
val hideErrorMessage: () -> Unit
) : BrowseEvent() ) : BrowseEvent()
data class OnStoragePermissionDismiss( data class OnStoragePermissionDismiss(
val permissionState: PermissionState, val permissionState: PermissionState
val showErrorMessage: () -> Unit
) : BrowseEvent() ) : BrowseEvent()
data object OnRefreshList : BrowseEvent() data object OnRefreshList : BrowseEvent()
data class OnPermissionCheck( data class OnPermissionCheck(
val permissionState: PermissionState, val permissionState: PermissionState
val hideErrorMessage: () -> Unit ) : BrowseEvent()
data class OnSelectFile(
val includedFileFormats: List<String>,
val file: SelectableFile
) : BrowseEvent()
data class OnSelectFiles(
val includedFileFormats: List<String>,
val files: List<SelectableFile>
) : BrowseEvent() ) : BrowseEvent()
data class OnSelectFile(val file: Pair<File, Selected>) : BrowseEvent()
data class OnSelectBook(val book: Pair<NullableBook, Selected>) : BrowseEvent() data class OnSelectBook(val book: Pair<NullableBook, Selected>) : BrowseEvent()
data object OnSearchShowHide : BrowseEvent() data object OnSearchShowHide : BrowseEvent()
data class OnRequestFocus(val focusRequester: FocusRequester) : BrowseEvent() data class OnRequestFocus(val focusRequester: FocusRequester) : BrowseEvent()
@ -50,4 +58,11 @@ sealed class BrowseEvent {
data object OnLoadList : BrowseEvent() data object OnLoadList : BrowseEvent()
data object OnUpdateScrollOffset : 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 package ua.acclorite.book_story.presentation.screens.browse.data
import android.os.Environment
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.Selected import ua.acclorite.book_story.domain.util.Selected
import java.io.File import java.io.File
@Immutable @Immutable
data class BrowseState( data class BrowseState(
val selectableFiles: List<Pair<File, Selected>> = emptyList(), val selectableFiles: List<SelectableFile> = emptyList(),
val listState: LazyListState = LazyListState(0, 0), 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 isLoading: Boolean = true,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isError: Boolean = false,
val requestPermissionDialog: Boolean = false, val requestPermissionDialog: Boolean = false,
val selectedItemsCount: Int = 0, val selectedItemsCount: Int = 0,
@ -20,6 +31,7 @@ data class BrowseState(
val showSearch: Boolean = false, val showSearch: Boolean = false,
val searchQuery: String = "", val searchQuery: String = "",
val hasSearched: Boolean = false,
val hasFocused: Boolean = false, val hasFocused: Boolean = false,
val showAddingDialog: 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 package ua.acclorite.book_story.presentation.screens.browse.data
import android.content.Intent import android.content.Intent
@ -6,6 +8,7 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -21,35 +24,43 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import ua.acclorite.book_story.domain.model.NullableBook 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.GetFilesFromDevice
import ua.acclorite.book_story.domain.use_case.InsertBook 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.Screen
import ua.acclorite.book_story.presentation.data.launchActivity 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 import javax.inject.Inject
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@HiltViewModel @HiltViewModel
class BrowseViewModel @Inject constructor( class BrowseViewModel @Inject constructor(
private val getBooksFromFiles: GetBooksFromFiles, private val getBookFromFile: GetBookFromFile,
private val getFilesFromDevice: GetFilesFromDevice, private val getFilesFromDevice: GetFilesFromDevice,
private val insertBook: InsertBook private val insertBook: InsertBook,
private val updateFavoriteDirectory: UpdateFavoriteDirectory,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(BrowseState()) private val _state = MutableStateFlow(BrowseState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var job: Job? = null private var searchQueryJob: Job? = null
private var job2: Job? = null private var refreshListJob: Job? = null
private var job3: Job? = null private var getBooksJob: Job? = null
private var storagePermissionJob: Job? = null private var storagePermissionJob: Job? = null
private var changeDirectoryJob: Job? = null
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
isLoading = true isLoading = true,
selectedDirectory = Environment.getExternalStorageDirectory(),
inNestedDirectory = false
) )
} }
getFilesFromDownloads() getFilesFromDownloads()
@ -70,10 +81,10 @@ class BrowseViewModel @Inject constructor(
if (isPermissionGranted) { if (isPermissionGranted) {
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = false requestPermissionDialog = false,
isError = false
) )
} }
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList) onEvent(BrowseEvent.OnRefreshList)
return return
} }
@ -130,10 +141,10 @@ class BrowseViewModel @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = false requestPermissionDialog = false,
isError = false
) )
} }
event.hideErrorMessage()
onEvent(BrowseEvent.OnRefreshList) onEvent(BrowseEvent.OnRefreshList)
break break
} }
@ -158,19 +169,24 @@ class BrowseViewModel @Inject constructor(
getFilesFromDownloads() getFilesFromDownloads()
} }
} else { } else {
event.showErrorMessage() _state.update {
it.copy(
isError = true
)
}
} }
} }
is BrowseEvent.OnRefreshList -> { is BrowseEvent.OnRefreshList -> {
job2?.cancel() refreshListJob?.cancel()
job2 = viewModelScope.launch(Dispatchers.IO) { refreshListJob = viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
isRefreshing = true, isRefreshing = true,
hasSelectedItems = false, hasSelectedItems = false,
showSearch = false, showSearch = false,
listState = LazyListState(0, 0) listState = LazyListState(),
gridState = LazyGridState(),
) )
} }
yield() yield()
@ -197,30 +213,94 @@ class BrowseViewModel @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
requestPermissionDialog = true requestPermissionDialog = true,
isError = false
) )
} }
event.hideErrorMessage()
} }
} }
is BrowseEvent.OnSelectFile -> { is BrowseEvent.OnSelectFile -> {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.selectableFiles.map { val editedList = _state.value.selectableFiles.map { file ->
if (event.file.first.path == it.first.path) { when (event.file.isDirectory) {
it.copy( false -> {
second = !it.second if (event.file.fileOrDirectory.path == file.fileOrDirectory.path) {
) file.copy(
} else { isSelected = !file.isSelected
it )
} else {
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 { _state.update {
it.copy( it.copy(
selectableFiles = editedList, selectableFiles = editedList,
selectedItemsCount = editedList.filter { file -> file.second }.size, selectedItemsCount = editedList.filter { file ->
hasSelectedItems = editedList.any { file -> file.second } 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 { _state.update {
it.copy( it.copy(
searchQuery = "", searchQuery = "",
hasSearched = false,
hasFocused = false hasFocused = false
) )
} }
@ -289,7 +370,11 @@ class BrowseViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
selectableFiles = it.selectableFiles.map { file -> file.copy(second = false) }, selectableFiles = it.selectableFiles.map { file ->
file.copy(
isSelected = false
)
},
hasSelectedItems = false hasSelectedItems = false
) )
} }
@ -302,8 +387,8 @@ class BrowseViewModel @Inject constructor(
searchQuery = event.query searchQuery = event.query
) )
} }
job?.cancel() searchQueryJob?.cancel()
job = viewModelScope.launch(Dispatchers.IO) { searchQueryJob = viewModelScope.launch(Dispatchers.IO) {
delay(500) delay(500)
yield() yield()
onEvent(BrowseEvent.OnSearch) onEvent(BrowseEvent.OnSearch)
@ -322,7 +407,7 @@ class BrowseViewModel @Inject constructor(
showAddingDialog = false showAddingDialog = false
) )
} }
job3?.cancel(null) getBooksJob?.cancel()
} }
is BrowseEvent.OnAddingDialogRequest -> { is BrowseEvent.OnAddingDialogRequest -> {
@ -336,7 +421,7 @@ class BrowseViewModel @Inject constructor(
} }
is BrowseEvent.OnGetBooksFromFiles -> { is BrowseEvent.OnGetBooksFromFiles -> {
job3 = viewModelScope.launch(Dispatchers.IO) { getBooksJob = viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
isBooksLoading = true isBooksLoading = true
@ -347,15 +432,11 @@ class BrowseViewModel @Inject constructor(
val books = mutableListOf<NullableBook>() val books = mutableListOf<NullableBook>()
_state.value.selectableFiles _state.value.selectableFiles
.filter { it.second } .filter { it.isSelected && !it.isDirectory }
.map { it.first } .map { it.fileOrDirectory }
.forEach { .forEach {
yield() yield()
books.add( books.add(getBookFromFile.execute(it))
getBooksFromFiles.execute(
listOf(it)
).first()
)
} }
yield() yield()
@ -422,7 +503,8 @@ class BrowseViewModel @Inject constructor(
it.copy( it.copy(
isLoading = true, isLoading = true,
hasSelectedItems = false, hasSelectedItems = false,
listState = LazyListState(0, 0) listState = LazyListState(),
gridState = LazyGridState()
) )
} }
getFilesFromDownloads() getFilesFromDownloads()
@ -435,29 +517,201 @@ class BrowseViewModel @Inject constructor(
listState = LazyListState( listState = LazyListState(
it.listState.firstVisibleItemIndex, it.listState.firstVisibleItemIndex,
0 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( private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else "" query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
) { ) {
getFilesFromDevice.execute(query).collect { result -> getFilesFromDevice.execute(query).apply {
when (result) { yield()
is Resource.Success -> { _state.update {
_state.update { it.copy(
it.copy( selectableFiles = this,
selectableFiles = result.data?.map { book -> Pair(book, false) } selectedItemsCount = 0,
?: emptyList(), hasSearched = query.isNotBlank(),
isLoading = false 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.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.DisplaySettings import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar 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)) } 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="dropped_tab">Кинуто</string>
<string name="general_tab">Головні</string> <string name="general_tab">Головні</string>
<string name="color_tab">Кольори</string> <string name="color_tab">Кольори</string>
<string name="filter_tab">Фільтр</string>
<string name="sort_tab">Сортування</string>
<!-- Settings categories --> <!-- Settings categories -->
<string name="general_settings">Головні</string> <string name="general_settings">Головні</string>
@ -195,6 +197,8 @@
<string name="appearance_settings_desc">Тема, кольори</string> <string name="appearance_settings_desc">Тема, кольори</string>
<string name="reader_settings">Читач</string> <string name="reader_settings">Читач</string>
<string name="reader_settings_desc">Шрифт, текст, перекладач</string> <string name="reader_settings_desc">Шрифт, текст, перекладач</string>
<string name="browse_settings">Огляд</string>
<string name="browse_settings_desc">Головні, фільтр, сортування</string>
<!-- Settings subcategories --> <!-- Settings subcategories -->
<string name="font_reader_settings">Шрифт</string> <string name="font_reader_settings">Шрифт</string>
@ -202,6 +206,9 @@
<string name="translator_reader_settings">Перекладач</string> <string name="translator_reader_settings">Перекладач</string>
<string name="theme_appearance_settings">Налаштування теми</string> <string name="theme_appearance_settings">Налаштування теми</string>
<string name="colors_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 --> <!-- Settings options -->
<string name="language_option">Мова застосунка</string> <string name="language_option">Мова застосунка</string>
@ -225,6 +232,13 @@
<string name="double_click_translation_option_desc">Двічі клацніть на абзац, щоб перекласти його</string> <string name="double_click_translation_option_desc">Двічі клацніть на абзац, щоб перекласти його</string>
<string name="fast_color_preset_change_option">Швидка зміна пресета кольорів</string> <string name="fast_color_preset_change_option">Швидка зміна пресета кольорів</string>
<string name="fast_color_preset_change_option_desc">Проведіть вліво або вправо, щоб змінити пресет кольорів</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 --> <!-- Color presets -->
<string name="red_color">Червоний</string> <string name="red_color">Червоний</string>
@ -251,6 +265,25 @@
<string name="font_style_italic">Похилий</string> <string name="font_style_italic">Похилий</string>
<string name="font_style_normal">Нормальний</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 --> <!-- Themes -->
<string name="dynamic_theme">Меркурій</string> <string name="dynamic_theme">Меркурій</string>
<string name="blue_theme">Нептун</string> <string name="blue_theme">Нептун</string>
@ -293,6 +326,9 @@
<string name="file_size">Розмір файлу</string> <string name="file_size">Розмір файлу</string>
<string name="unknown">Невідомо</string> <string name="unknown">Невідомо</string>
<!-- Browse -->
<string name="internal_storage">Внутрішнє Сховище</string>
<!-- Reader --> <!-- Reader -->
<string name="loading">Завантаження…</string> <string name="loading">Завантаження…</string>
<string name="happy_reading">Приємного читання!</string> <string name="happy_reading">Приємного читання!</string>
@ -512,6 +548,10 @@
<string name="open_reader_settings_content_desc">Налаштування читача</string> <string name="open_reader_settings_content_desc">Налаштування читача</string>
<string name="top_app_bar_anim_content_desc">Анімація верхньої панелі</string> <string name="top_app_bar_anim_content_desc">Анімація верхньої панелі</string>
<string name="file_icon_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="checkbox_content_desc">Галочка</string>
<string name="show_dropdown_content_desc">Випадаючий список</string> <string name="show_dropdown_content_desc">Випадаючий список</string>
<string name="cover_image_not_found_content_desc">Обкладенка не знайдена</string> <string name="cover_image_not_found_content_desc">Обкладенка не знайдена</string>
@ -520,6 +560,7 @@
<string name="search_content_desc">Пошук</string> <string name="search_content_desc">Пошук</string>
<string name="clear_selected_items_content_desc">Очистити обрані предмети</string> <string name="clear_selected_items_content_desc">Очистити обрані предмети</string>
<string name="add_files_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="exit_search_content_desc">Вийти з пошуку</string>
<string name="delete_whole_history_content_desc">Видалити всю історію</string> <string name="delete_whole_history_content_desc">Видалити всю історію</string>
<string name="move_books_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_project_content_desc">GitHub Проєкт</string>
<string name="github_profile_content_desc">GitHub Профіль</string> <string name="github_profile_content_desc">GitHub Профіль</string>
<string name="drag_content_desc">Тягнути</string> <string name="drag_content_desc">Тягнути</string>
<string name="filter_content_desc">Фільтр</string>
</resources> </resources>

View file

@ -206,6 +206,8 @@
<string name="dropped_tab">Dropped</string> <string name="dropped_tab">Dropped</string>
<string name="general_tab">General</string> <string name="general_tab">General</string>
<string name="color_tab">Colors</string> <string name="color_tab">Colors</string>
<string name="filter_tab">Filter</string>
<string name="sort_tab">Sort</string>
<!-- Settings categories --> <!-- Settings categories -->
<string name="general_settings">General</string> <string name="general_settings">General</string>
@ -214,6 +216,8 @@
<string name="appearance_settings_desc">Theme, colors</string> <string name="appearance_settings_desc">Theme, colors</string>
<string name="reader_settings">Reader</string> <string name="reader_settings">Reader</string>
<string name="reader_settings_desc">Font, text, translator</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 --> <!-- Settings subcategories -->
<string name="font_reader_settings">Font</string> <string name="font_reader_settings">Font</string>
@ -221,6 +225,9 @@
<string name="translator_reader_settings">Translator</string> <string name="translator_reader_settings">Translator</string>
<string name="theme_appearance_settings">Theme preferences</string> <string name="theme_appearance_settings">Theme preferences</string>
<string name="colors_appearance_settings">Colors</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 --> <!-- Settings options -->
<string name="language_option">App language</string> <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="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">Fast color preset change</string>
<string name="fast_color_preset_change_option_desc">Swipe left or right to change color preset</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 --> <!-- Color presets -->
<string name="red_color">Red</string> <string name="red_color">Red</string>
@ -270,6 +284,25 @@
<string name="font_style_italic">Italic</string> <string name="font_style_italic">Italic</string>
<string name="font_style_normal">Normal</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 --> <!-- Themes -->
<string name="dynamic_theme">Mercury</string> <string name="dynamic_theme">Mercury</string>
<string name="blue_theme">Neptune</string> <string name="blue_theme">Neptune</string>
@ -312,6 +345,9 @@
<string name="file_size">File size</string> <string name="file_size">File size</string>
<string name="unknown">Unknown</string> <string name="unknown">Unknown</string>
<!-- Browse -->
<string name="internal_storage">Internal Storage</string>
<!-- Reader --> <!-- Reader -->
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="happy_reading">Happy reading!</string> <string name="happy_reading">Happy reading!</string>
@ -530,6 +566,10 @@
<string name="open_reader_settings_content_desc">Reader settings</string> <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="top_app_bar_anim_content_desc">Top app bar animation</string>
<string name="file_icon_content_desc">File</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="checkbox_content_desc">Checkbox</string>
<string name="show_dropdown_content_desc">Dropdown</string> <string name="show_dropdown_content_desc">Dropdown</string>
<string name="cover_image_not_found_content_desc">Cover image not found</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="search_content_desc">Search</string>
<string name="clear_selected_items_content_desc">Clear selected items</string> <string name="clear_selected_items_content_desc">Clear selected items</string>
<string name="add_files_content_desc">Add files</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="exit_search_content_desc">Exit search</string>
<string name="delete_whole_history_content_desc">Delete whole history</string> <string name="delete_whole_history_content_desc">Delete whole history</string>
<string name="move_books_content_desc">Move selected books</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_project_content_desc">GitHub Project</string>
<string name="github_profile_content_desc">GitHub Profile</string> <string name="github_profile_content_desc">GitHub Profile</string>
<string name="drag_content_desc">Drag</string> <string name="drag_content_desc">Drag</string>
<string name="filter_content_desc">Filter</string>
</resources> </resources>