diff --git a/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/6.json b/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/6.json new file mode 100644 index 00000000..173d2e0c --- /dev/null +++ b/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/6.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/Activity.kt b/app/src/main/java/ua/acclorite/book_story/Activity.kt index 52a14baf..076d3782 100644 --- a/app/src/main/java/ua/acclorite/book_story/Activity.kt +++ b/app/src/main/java/ua/acclorite/book_story/Activity.kt @@ -40,6 +40,7 @@ import ua.acclorite.book_story.presentation.screens.reader.ReaderScreenRoot import ua.acclorite.book_story.presentation.screens.settings.SettingsScreenRoot import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettingsRoot +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.BrowseSettingsRoot import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettingsRoot import ua.acclorite.book_story.presentation.screens.settings.nested.reader.ReaderSettingsRoot import ua.acclorite.book_story.presentation.screens.start.StartScreenRoot @@ -180,6 +181,9 @@ class Activity : AppCompatActivity() { composable { ReaderSettingsRoot() } + composable { + BrowseSettingsRoot() + } // About screen composable { diff --git a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt index c8d667c2..cee3b25f 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt @@ -44,6 +44,7 @@ object AppModule { .addMigrations( DatabaseHelper.MIGRATION_2_3, // creates LanguageHistoryEntity table(if does not exist) DatabaseHelper.MIGRATION_4_5, // creates ColorPresetEntity table(if does not exist) + DatabaseHelper.MIGRATION_5_6, // creates FavoriteDirectoryEntity table(if does not exist) ) .allowMainThreadQueries() .build() diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/dto/FavoriteDirectoryEntity.kt b/app/src/main/java/ua/acclorite/book_story/data/local/dto/FavoriteDirectoryEntity.kt new file mode 100644 index 00000000..37302626 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/data/local/dto/FavoriteDirectoryEntity.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDao.kt b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDao.kt index 1ae70f93..8506210a 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDao.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import androidx.room.Upsert import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.ColorPresetEntity +import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity @Dao @@ -78,4 +79,14 @@ interface BookDao { @Query("DELETE FROM colorpresetentity") suspend fun deleteColorPresets() + + + @Upsert + suspend fun insertFavoriteDirectory(favoriteDirectoryEntity: FavoriteDirectoryEntity) + + @Query("SELECT EXISTS(SELECT 1 FROM favoritedirectoryentity WHERE path = :path LIMIT 1)") + suspend fun favoriteDirectoryExits(path: String): Boolean + + @Delete + suspend fun deleteFavoriteDirectory(favoriteDirectoryEntity: FavoriteDirectoryEntity) } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt index ad2ec727..6d13cd24 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt @@ -10,6 +10,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.ColorPresetEntity +import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity @Database( @@ -17,13 +18,15 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity BookEntity::class, HistoryEntity::class, ColorPresetEntity::class, + FavoriteDirectoryEntity::class, ], - version = 5, + version = 6, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4, spec = DatabaseHelper.MIGRATION_3_4::class), AutoMigration(4, 5), + AutoMigration(5, 6), ], exportSchema = true ) @@ -68,4 +71,15 @@ object DatabaseHelper { ) } } + + val MIGRATION_5_6 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `FavoriteDirectoryEntity` (" + + "`path` TEXT NOT NULL, " + + "PRIMARY KEY(`path`)" + + ")" + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt index 9f4f88ce..fd38b66d 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import ua.acclorite.book_story.R import ua.acclorite.book_story.data.local.data_store.DataStore +import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity import ua.acclorite.book_story.data.local.notification.UpdatesNotificationService import ua.acclorite.book_story.data.local.room.BookDao import ua.acclorite.book_story.data.mapper.book.BookMapper @@ -32,6 +33,7 @@ import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.ColorPreset import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.NullableBook +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.CoverImage @@ -464,19 +466,82 @@ class BookRepositoryImpl @Inject constructor( return result.await() } - override suspend fun getFilesFromDevice(query: String): Flow>> { - fun getAllFilesInDirectory(directory: File): List { - val filesList = mutableListOf() + override suspend fun getFilesFromDevice(query: String): List { + val existingBooks = database + .searchBooks("") + .map { bookMapper.toBook(it) } + val supportedExtensions = Constants.EXTENSIONS - val files = directory.listFiles() + fun File.isValid(): Boolean { + if (!exists()) { + return false + } + + val isFileSupported = supportedExtensions.any { ext -> + name.endsWith( + ext, + ignoreCase = true + ) + } + + if (!isFileSupported) { + return false + } + + val isFileNotAdded = existingBooks.all { + it.filePath.lowercase().trim() != path.lowercase().trim() + } + + if (!isFileNotAdded) { + return false + } + + val isQuery = if (query.isEmpty()) true else name.trim().lowercase() + .contains(query.trim().lowercase()) + + return isQuery + } + + suspend fun File.getAllFiles(): List { + val filesList = mutableListOf() + + val files = listFiles() if (files != null) { for (file in files) { - if (file.isFile) { - filesList.add(file) + if (!file.exists()) { + continue } - if (file.isDirectory) { - val subDirectoryFiles = getAllFilesInDirectory(file) - filesList.addAll(subDirectoryFiles) + + when { + file.isFile -> { + if (file.isValid()) { + filesList.add( + SelectableFile( + fileOrDirectory = file, + parentDirectory = this, + isDirectory = false, + isFavorite = false, + isSelected = false + ) + ) + } + } + + file.isDirectory -> { + val subDirectoryFiles = file.getAllFiles() + if (subDirectoryFiles.isNotEmpty()) { + filesList.add( + SelectableFile( + fileOrDirectory = file, + parentDirectory = this, + isDirectory = true, + isFavorite = database.favoriteDirectoryExits(file.path), + isSelected = false + ) + ) + filesList.addAll(subDirectoryFiles) + } + } } } } @@ -484,77 +549,17 @@ class BookRepositoryImpl @Inject constructor( return filesList } - return flow { - val existingBooks = database - .searchBooks("") - .map { bookMapper.toBook(it) } - - val primaryDirectory = Environment.getExternalStorageDirectory() - - if ( - !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() - - 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 - ) - ) + val rootDirectory = Environment.getExternalStorageDirectory() + if ( + !rootDirectory.exists() || + !rootDirectory.isDirectory || + (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED && + Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED_READ_ONLY) + ) { + return emptyList() } + + return rootDirectory.getAllFiles() } override suspend fun getBooksFromFiles(files: List): List { @@ -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) + ) + } } diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/SelectableFile.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/SelectableFile.kt new file mode 100644 index 00000000..aba649de --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/SelectableFile.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt index ff6c45cd..a0c6669e 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt @@ -8,6 +8,7 @@ import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.ColorPreset import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.NullableBook +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.presentation.data.MainState @@ -71,7 +72,7 @@ interface BookRepository { suspend fun getAllSettings(scope: CoroutineScope): MainState - suspend fun getFilesFromDevice(query: String = ""): Flow>> + suspend fun getFilesFromDevice(query: String = ""): List suspend fun getBooksFromFiles(files: List): List @@ -103,4 +104,7 @@ interface BookRepository { suspend fun reorderColorPresets(orderedColorPresets: List) suspend fun deleteColorPreset(colorPreset: ColorPreset) + + + suspend fun updateFavoriteDirectory(path: String) } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBooksFromFiles.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBooksFromFiles.kt deleted file mode 100644 index 48bf6413..00000000 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBooksFromFiles.kt +++ /dev/null @@ -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): List { - return repository.getBooksFromFiles(files) - } -} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetFilesFromDevice.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetFilesFromDevice.kt index 83806822..8e7a1ae3 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetFilesFromDevice.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetFilesFromDevice.kt @@ -1,16 +1,14 @@ package ua.acclorite.book_story.domain.use_case -import kotlinx.coroutines.flow.Flow +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.repository.BookRepository -import ua.acclorite.book_story.domain.util.Resource -import java.io.File import javax.inject.Inject class GetFilesFromDevice @Inject constructor( private val repository: BookRepository ) { - suspend fun execute(query: String): Flow>> { + suspend fun execute(query: String): List { return repository.getFilesFromDevice(query) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateFavoriteDirectory.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateFavoriteDirectory.kt new file mode 100644 index 00000000..ef143fb9 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateFavoriteDirectory.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt index f60350d4..612e83d7 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt @@ -27,7 +27,7 @@ object Constants { const val MAIN_STATE = "main_state" // Supported file extensions - val EXTENSIONS = listOf(".txt", ".pdf", ".epub", ".fb2") + val EXTENSIONS = listOf(".epub", ".pdf", ".fb2", ".txt") // Supported languages val LANGUAGES = listOf( diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt index 3fa68fb0..348d3ae6 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt @@ -3,23 +3,36 @@ package ua.acclorite.book_story.domain.util import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey object DataStoreConstants { - // App settings + // General settings val LANGUAGE = stringPreferencesKey("language") val THEME = stringPreferencesKey("theme") val DARK_THEME = stringPreferencesKey("dark_theme") val PURE_DARK = stringPreferencesKey("pure_dark") val THEME_CONTRAST = stringPreferencesKey("theme_contrast") val SHOW_START_SCREEN = booleanPreferencesKey("guide") + val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates") + + // Reader settings + val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation") + val FAST_COLOR_PRESET_CHANGE = booleanPreferencesKey("fast_color_preset_change") + val SIDE_PADDING = intPreferencesKey("side_padding") val FONT = stringPreferencesKey("font") val IS_ITALIC = booleanPreferencesKey("font_style") val FONT_SIZE = intPreferencesKey("font_size") val LINE_HEIGHT = intPreferencesKey("line_height") val PARAGRAPH_HEIGHT = intPreferencesKey("paragraph_height") val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation") - val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates") - val SIDE_PADDING = intPreferencesKey("side_padding") - val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation") - val FAST_COLOR_PRESET_CHANGE = booleanPreferencesKey("fast_color_preset_change") + + // Browse settings + val BROWSE_FILES_STRUCTURE = stringPreferencesKey("browse_files_structure") + val BROWSE_LAYOUT = stringPreferencesKey("browse_layout") + val BROWSE_AUTO_GRID_SIZE = booleanPreferencesKey("browse_auto_grid_size") + val BROWSE_GRID_SIZE = intPreferencesKey("browse_grid_size") + val BROWSE_PIN_FAVORITE_DIRECTORIES = booleanPreferencesKey("browse_pin_favorite_directories") + val BROWSE_SORT_ORDER = stringPreferencesKey("browse_sort_order") + val BROWSE_SORT_ORDER_DESCENDING = booleanPreferencesKey("browse_sort_order_descending") + val BROWSE_INCLUDED_FILTER_ITEMS = stringSetPreferencesKey("browse_included_filter_items") } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt index f4184c51..6388907c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt @@ -1,5 +1,8 @@ package ua.acclorite.book_story.presentation.data +import androidx.compose.runtime.Immutable + +@Immutable sealed class MainEvent { data class OnChangeLanguage(val lang: String) : MainEvent() data class OnChangeTheme(val theme: String) : MainEvent() @@ -17,4 +20,12 @@ sealed class MainEvent { data class OnChangeSidePadding(val sidePadding: Int) : MainEvent() data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent() data class OnChangeFastColorPresetChange(val bool: Boolean) : MainEvent() + data class OnChangeBrowseFilesStructure(val structure: String) : MainEvent() + data class OnChangeBrowseLayout(val layout: String) : MainEvent() + data class OnChangeBrowseAutoGridSize(val bool: Boolean) : MainEvent() + data class OnChangeBrowseGridSize(val size: Int) : MainEvent() + data class OnChangeBrowsePinFavoriteDirectories(val bool: Boolean) : MainEvent() + data class OnChangeBrowseSortOrder(val order: String) : MainEvent() + data class OnChangeBrowseSortOrderDescending(val bool: Boolean) : MainEvent() + data class OnChangeBrowseIncludedFilterItem(val item: String) : MainEvent() } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt index cbaa72a5..235c946b 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt @@ -1,3 +1,5 @@ +@file:Suppress("UNCHECKED_CAST") + package ua.acclorite.book_story.presentation.data import android.os.Build @@ -7,6 +9,12 @@ import androidx.compose.runtime.Immutable import kotlinx.parcelize.Parcelize import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.DataStoreConstants +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseLayout +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseSortOrder +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseLayout +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseSortOrder +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toFilesStructure import ua.acclorite.book_story.presentation.ui.DarkTheme import ua.acclorite.book_story.presentation.ui.PureDark import ua.acclorite.book_story.presentation.ui.Theme @@ -42,6 +50,14 @@ data class MainState( val sidePadding: Int? = null, val doubleClickTranslation: Boolean? = null, val fastColorPresetChange: Boolean? = null, + val browseFilesStructure: BrowseFilesStructure? = null, + val browseLayout: BrowseLayout? = null, + val browseAutoGridSize: Boolean? = null, + val browseGridSize: Int? = null, + val browsePinFavoriteDirectories: Boolean? = null, + val browseSortOrder: BrowseSortOrder? = null, + val browseSortOrderDescending: Boolean? = null, + val browseIncludedFilterItems: List? = null, ) : Parcelable { companion object { /** @@ -105,6 +121,37 @@ data class MainState( data[FAST_COLOR_PRESET_CHANGE.name] as? Boolean ?: true + val browseFilesStructure: String = data[BROWSE_FILES_STRUCTURE.name] as? String + ?: BrowseFilesStructure.DIRECTORIES.name + + val browseLayout: String = data[BROWSE_LAYOUT.name] as? String + ?: BrowseLayout.LIST.name + + val browseAutoGridSize: Boolean = + data[BROWSE_AUTO_GRID_SIZE.name] as? Boolean + ?: true + + val browseGridSize: Int = + data[BROWSE_GRID_SIZE.name] as? Int + ?: 0 + + val browsePinFavoriteDirectories: Boolean = + data[BROWSE_PIN_FAVORITE_DIRECTORIES.name] as? Boolean + ?: true + + val browseSortOrder: String = + data[BROWSE_SORT_ORDER.name] as? String + ?: BrowseSortOrder.LAST_MODIFIED.name + + val browseSortOrderDescending: Boolean = + data[BROWSE_SORT_ORDER_DESCENDING.name] as? Boolean + ?: true + + + val browseIncludedFilterItems = + (data[BROWSE_INCLUDED_FILTER_ITEMS.name] as? Set)?.toList() + ?: emptyList() + return MainState( language = language, theme = theme.toTheme(), @@ -121,7 +168,15 @@ data class MainState( checkForUpdates = checkForUpdates, sidePadding = sidePadding, doubleClickTranslation = doubleClickTranslation, - fastColorPresetChange = fastColorPresetChange + fastColorPresetChange = fastColorPresetChange, + browseFilesStructure = browseFilesStructure.toFilesStructure(), + browseLayout = browseLayout.toBrowseLayout(), + browseAutoGridSize = browseAutoGridSize, + browseGridSize = browseGridSize, + browsePinFavoriteDirectories = browsePinFavoriteDirectories, + browseSortOrder = browseSortOrder.toBrowseSortOrder(), + browseSortOrderDescending = browseSortOrderDescending, + browseIncludedFilterItems = browseIncludedFilterItems ) } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt index fc8c2837..a631d026 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt @@ -20,6 +20,9 @@ import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.DataStoreConstants import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseLayout +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toBrowseSortOrder +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.toFilesStructure import ua.acclorite.book_story.presentation.ui.toDarkTheme import ua.acclorite.book_story.presentation.ui.toPureDark import ua.acclorite.book_story.presentation.ui.toTheme @@ -228,6 +231,105 @@ class MainViewModel @Inject constructor( } } } + + is MainEvent.OnChangeBrowseFilesStructure -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.BROWSE_FILES_STRUCTURE, event.structure) + updateStateWithSavedHandle { + it.copy( + browseFilesStructure = event.structure.toFilesStructure() + ) + } + } + } + + is MainEvent.OnChangeBrowseLayout -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.BROWSE_LAYOUT, event.layout) + updateStateWithSavedHandle { + it.copy( + browseLayout = event.layout.toBrowseLayout() + ) + } + } + } + + is MainEvent.OnChangeBrowseAutoGridSize -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.BROWSE_AUTO_GRID_SIZE, event.bool) + updateStateWithSavedHandle { + it.copy( + browseAutoGridSize = event.bool + ) + } + } + } + + is MainEvent.OnChangeBrowseGridSize -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.BROWSE_GRID_SIZE, event.size) + updateStateWithSavedHandle { + it.copy( + browseGridSize = event.size + ) + } + } + } + + is MainEvent.OnChangeBrowsePinFavoriteDirectories -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute( + DataStoreConstants.BROWSE_PIN_FAVORITE_DIRECTORIES, + event.bool + ) + updateStateWithSavedHandle { + it.copy( + browsePinFavoriteDirectories = event.bool + ) + } + } + } + + is MainEvent.OnChangeBrowseSortOrder -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.BROWSE_SORT_ORDER, event.order) + updateStateWithSavedHandle { + it.copy( + browseSortOrder = event.order.toBrowseSortOrder() + ) + } + } + } + + is MainEvent.OnChangeBrowseSortOrderDescending -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute( + DataStoreConstants.BROWSE_SORT_ORDER_DESCENDING, + event.bool + ) + updateStateWithSavedHandle { + it.copy( + browseSortOrderDescending = event.bool + ) + } + } + } + + is MainEvent.OnChangeBrowseIncludedFilterItem -> { + viewModelScope.launch(Dispatchers.IO) { + val set = _state.value.browseIncludedFilterItems!!.toMutableSet() + if (!set.add(event.item)) { + set.remove(event.item) + } + + setDatastore.execute(DataStoreConstants.BROWSE_INCLUDED_FILTER_ITEMS, set) + updateStateWithSavedHandle { + it.copy( + browseIncludedFilterItems = set.toList() + ) + } + } + } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/Navigation.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/Navigation.kt index a7cc560e..1296bfd0 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/Navigation.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/Navigation.kt @@ -108,6 +108,10 @@ sealed class Screen : Parcelable { @Parcelize @Keep data object ReaderSettings : Screen() + + @Parcelize + @Keep + data object BrowseSettings : Screen() } @Parcelize diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/BrowseScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/BrowseScreen.kt index fbeba8ba..f6046058 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/BrowseScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/BrowseScreen.kt @@ -3,126 +3,125 @@ package ua.acclorite.book_story.presentation.screens.browse import android.Manifest import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Search import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.rememberPermissionState import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.util.OnNavigate -import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar -import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility -import ua.acclorite.book_story.presentation.components.CustomIconButton -import ua.acclorite.book_story.presentation.components.CustomSearchTextField -import ua.acclorite.book_story.presentation.components.MoreDropDown -import ua.acclorite.book_story.presentation.components.customItems -import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty -import ua.acclorite.book_story.presentation.components.is_messages.IsError import ua.acclorite.book_story.presentation.data.LocalNavigator +import ua.acclorite.book_story.presentation.data.MainEvent +import ua.acclorite.book_story.presentation.data.MainState +import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.Screen -import ua.acclorite.book_story.presentation.screens.browse.components.BrowseFileItem +import ua.acclorite.book_story.presentation.screens.browse.components.BrowseEmptyPlaceholder import ua.acclorite.book_story.presentation.screens.browse.components.BrowseStoragePermissionDialog import ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog.BrowseAddingDialog +import ua.acclorite.book_story.presentation.screens.browse.components.filter_bottom_sheet.BrowseFilterBottomSheet +import ua.acclorite.book_story.presentation.screens.browse.components.layout.BrowseLayout +import ua.acclorite.book_story.presentation.screens.browse.components.top_bar.BrowseTopBar import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseState import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure import ua.acclorite.book_story.presentation.ui.DefaultTransition -import ua.acclorite.book_story.presentation.ui.Transitions +@OptIn(ExperimentalPermissionsApi::class) @Composable fun BrowseScreenRoot() { val navigator = LocalNavigator.current val viewModel: BrowseViewModel = hiltViewModel() val libraryViewModel: LibraryViewModel = hiltViewModel() + val mainViewModel: MainViewModel = hiltViewModel() val state = viewModel.state.collectAsState() + val mainState = mainViewModel.state.collectAsState() + + val permissionState = rememberPermissionState( + permission = Manifest.permission.READ_EXTERNAL_STORAGE + ) + val filteredFiles = remember( + state.value.selectableFiles, + state.value.selectedDirectory + ) { + derivedStateOf { + viewModel.filterList(mainState.value) + } + } + + LaunchedEffect(Unit) { + viewModel.onEvent(BrowseEvent.OnUpdateScrollOffset) + viewModel.onEvent( + BrowseEvent.OnPermissionCheck(permissionState) + ) + } + BrowseScreen( state = state, + mainState = mainState, + permissionState = permissionState, + filteredFiles = filteredFiles.value, onNavigate = { navigator.it() }, onEvent = viewModel::onEvent, + onMainEvent = mainViewModel::onEvent, onLibraryEvent = libraryViewModel::onEvent ) + + DisposableEffect(Unit) { + onDispose { + viewModel.clearViewModel() + } + } } @OptIn( - ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, ExperimentalMaterialApi::class ) @Composable private fun BrowseScreen( state: State, + mainState: State, + permissionState: PermissionState, + filteredFiles: List, onNavigate: OnNavigate, onEvent: (BrowseEvent) -> Unit, + onMainEvent: (MainEvent) -> Unit, onLibraryEvent: (LibraryEvent) -> Unit ) { val context = LocalContext.current - val permissionState = rememberPermissionState( - permission = Manifest.permission.READ_EXTERNAL_STORAGE - ) val refreshState = rememberPullRefreshState( refreshing = state.value.isRefreshing, onRefresh = { onEvent(BrowseEvent.OnRefreshList) } ) - val focusRequester = remember { FocusRequester() } - var showErrorMessage by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - onEvent(BrowseEvent.OnUpdateScrollOffset) - onEvent( - BrowseEvent.OnPermissionCheck( - permissionState, - hideErrorMessage = { showErrorMessage = false } - ) - ) - } if (state.value.requestPermissionDialog) { - BrowseStoragePermissionDialog(onEvent, permissionState) { - showErrorMessage = it - } + BrowseStoragePermissionDialog(onEvent, permissionState) } if (state.value.showAddingDialog) { BrowseAddingDialog( @@ -131,6 +130,14 @@ private fun BrowseScreen( onLibraryEvent = onLibraryEvent ) } + if (state.value.showFilterBottomSheet) { + BrowseFilterBottomSheet( + state = state, + mainState = mainState, + onMainEvent = onMainEvent, + onEvent = onEvent + ) + } Scaffold( modifier = Modifier @@ -138,90 +145,11 @@ private fun BrowseScreen( .pullRefresh(refreshState), containerColor = MaterialTheme.colorScheme.surface, topBar = { - AnimatedTopAppBar( - scrollBehavior = null, - isTopBarScrolled = state.value.hasSelectedItems || state.value.listState.canScrollBackward, - - content1Visibility = !state.value.hasSelectedItems && !state.value.showSearch, - content1NavigationIcon = {}, - content1Title = { - Text(stringResource(id = R.string.browse_screen)) - }, - content1Actions = { - CustomIconButton( - icon = Icons.Default.Search, - contentDescription = R.string.search_content_desc, - disableOnClick = true - ) { - onEvent(BrowseEvent.OnSearchShowHide) - } - MoreDropDown() - }, - - content2Visibility = state.value.hasSelectedItems, - content2NavigationIcon = { - CustomIconButton( - icon = Icons.Default.Clear, - contentDescription = R.string.clear_selected_items_content_desc, - disableOnClick = true - ) { - onEvent(BrowseEvent.OnClearSelectedFiles) - } - }, - content2Title = { - Text( - stringResource( - id = R.string.selected_items_count_query, - state.value.selectedItemsCount.coerceAtLeast(1) - ), - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - }, - content2Actions = { - CustomIconButton( - icon = Icons.Default.Check, - contentDescription = R.string.add_files_content_desc, - disableOnClick = false, - enabled = !state.value.showAddingDialog - ) { - onEvent(BrowseEvent.OnAddingDialogRequest) - } - }, - - content3Visibility = state.value.showSearch && !state.value.hasSelectedItems, - content3NavigationIcon = { - CustomIconButton( - icon = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = R.string.exit_search_content_desc, - disableOnClick = true - ) { - onEvent(BrowseEvent.OnSearchShowHide) - } - }, - content3Title = { - CustomSearchTextField( - modifier = Modifier - .focusRequester(focusRequester) - .onGloballyPositioned { - onEvent(BrowseEvent.OnRequestFocus(focusRequester)) - }, - query = state.value.searchQuery, - onQueryChange = { - onEvent(BrowseEvent.OnSearchQueryChange(it)) - }, - onSearch = { - onEvent(BrowseEvent.OnSearch) - }, - placeholder = stringResource( - id = R.string.search_query, - stringResource(id = R.string.files) - ) - ) - }, - content3Actions = { - MoreDropDown() - } + BrowseTopBar( + state = state, + mainState = mainState, + filteredFiles = filteredFiles, + onEvent = onEvent ) } ) { padding -> @@ -231,86 +159,81 @@ private fun BrowseScreen( .padding(top = padding.calculateTopPadding()) ) { DefaultTransition(visible = !state.value.isLoading) { - LazyColumn( - state = state.value.listState, - modifier = Modifier - .fillMaxSize() - ) { - item { - Spacer(modifier = Modifier.height(8.dp)) - } - - customItems( - state.value.selectableFiles, - key = { it.first.path } - ) { selectableFile -> - BrowseFileItem( - file = selectableFile, - modifier = Modifier.animateItem(), - hasSelectedFiles = state.value.selectableFiles.any { it.second }, - onLongClick = { + BrowseLayout( + state = state, + mainState = mainState, + filteredFiles = filteredFiles, + onLongItemClick = { selectableFile -> + when (selectableFile.isDirectory) { + false -> { Toast.makeText( context, context.getString( R.string.file_path_query, - selectableFile.first.path + selectableFile.fileOrDirectory.path ), Toast.LENGTH_LONG ).show() - }, - onClick = { - onEvent(BrowseEvent.OnSelectFile(selectableFile)) } + + 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 { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - - CustomAnimatedVisibility( - visible = showErrorMessage, - modifier = Modifier.align(Alignment.Center), - enter = Transitions.DefaultTransitionIn, - exit = fadeOut(tween(0)) - ) { - IsError( - modifier = Modifier.align(Alignment.Center), - errorMessage = stringResource(id = R.string.error_permission), - icon = painterResource(id = R.drawable.error), - actionTitle = stringResource(id = R.string.grant_permission) - ) { - 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)) + true -> { + if (!state.value.hasSelectedItems) { + onEvent( + BrowseEvent.OnChangeDirectory( + selectableFile.fileOrDirectory, + savePreviousDirectory = true + ) + ) + } else { + onEvent( + BrowseEvent.OnSelectFile( + includedFileFormats = mainState.value.browseIncludedFilterItems!!, + file = selectableFile + ) + ) + } + } } } ) } + BrowseEmptyPlaceholder( + state = state, + isFilesEmpty = filteredFiles.isEmpty(), + storagePermissionState = permissionState, + onNavigate = onNavigate, + onEvent = onEvent + ) + PullRefreshIndicator( state.value.isRefreshing, refreshState, @@ -332,17 +255,18 @@ private fun BrowseScreen( return@BackHandler } + if ( + state.value.inNestedDirectory + && mainState.value.browseFilesStructure != BrowseFilesStructure.ALL_FILES + ) { + onEvent( + BrowseEvent.OnGoBackDirectory + ) + return@BackHandler + } + onNavigate { navigate(Screen.Library) } } -} - - - - - - - - - +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseEmptyPlaceholder.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseEmptyPlaceholder.kt new file mode 100644 index 00000000..437ef621 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseEmptyPlaceholder.kt @@ -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, + 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)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt deleted file mode 100644 index b9936f55..00000000 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt +++ /dev/null @@ -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, - 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 - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseStoragePermissionDialog.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseStoragePermissionDialog.kt index c17817fe..d901035e 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseStoragePermissionDialog.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseStoragePermissionDialog.kt @@ -14,13 +14,15 @@ import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent /** * Storage permission dialog. + * + * @param onEvent [BrowseEvent] callback. + * @param permissionState Storage [PermissionState]. */ @OptIn(ExperimentalPermissionsApi::class) @Composable fun BrowseStoragePermissionDialog( onEvent: (BrowseEvent) -> Unit, - permissionState: PermissionState, - showErrorMessage: (Boolean) -> Unit + permissionState: PermissionState ) { val activity = LocalContext.current as ComponentActivity @@ -32,8 +34,7 @@ fun BrowseStoragePermissionDialog( onDismiss = { onEvent( BrowseEvent.OnStoragePermissionDismiss( - permissionState, - showErrorMessage = { showErrorMessage(true) } + permissionState = permissionState ) ) }, @@ -43,9 +44,8 @@ fun BrowseStoragePermissionDialog( onAction = { onEvent( BrowseEvent.OnStoragePermissionRequest( - activity, - storagePermissionState = permissionState, - hideErrorMessage = { showErrorMessage(false) } + activity = activity, + storagePermissionState = permissionState ) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheet.kt new file mode 100644 index 00000000..8c3e275c --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheet.kt @@ -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, + mainState: State, + 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 + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheetTabRow.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheetTabRow.kt new file mode 100644 index 00000000..94ef49a1 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/filter_bottom_sheet/BrowseFilterBottomSheetTabRow.kt @@ -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 + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseItem.kt new file mode 100644 index 00000000..6709d738 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseItem.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseLayout.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseLayout.kt new file mode 100644 index 00000000..19a6e261 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/BrowseLayout.kt @@ -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, + mainState: State, + filteredFiles: List, + 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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridDirectoryItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridDirectoryItem.kt new file mode 100644 index 00000000..873ecdac --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridDirectoryItem.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridFileItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridFileItem.kt new file mode 100644 index 00000000..34aa1d28 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridFileItem.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridItem.kt new file mode 100644 index 00000000..e2362748 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridItem.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridLayout.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridLayout.kt new file mode 100644 index 00000000..33e02c00 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/grid/BrowseGridLayout.kt @@ -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, + gridSize: Int, + autoGridSize: Boolean, + filteredFiles: List, + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListDirectoryItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListDirectoryItem.kt new file mode 100644 index 00000000..e6c3c9fd --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListDirectoryItem.kt @@ -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() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListFileItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListFileItem.kt new file mode 100644 index 00000000..cf0139f0 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListFileItem.kt @@ -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() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListItem.kt new file mode 100644 index 00000000..80ee3878 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListItem.kt @@ -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)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListLayout.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListLayout.kt new file mode 100644 index 00000000..9dd3bb93 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/layout/list/BrowseListLayout.kt @@ -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, + filteredFiles: List, + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBar.kt new file mode 100644 index 00000000..567353fb --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBar.kt @@ -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, + mainState: State, + filteredFiles: List, + 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 + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBarDirectoryPath.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBarDirectoryPath.kt new file mode 100644 index 00000000..202edeec --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/top_bar/BrowseTopBarDirectoryPath.kt @@ -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, + mainState: State, + onEvent: (BrowseEvent) -> Unit +) { + val context = LocalContext.current + val rootDirectory = remember { + Environment.getExternalStorageDirectory() + } + val directories = remember(state.value.selectedDirectory) { + val directories = mutableListOf>() + + 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)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseEvent.kt index c75f657e..f4055be4 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseEvent.kt @@ -3,11 +3,13 @@ package ua.acclorite.book_story.presentation.screens.browse.data import androidx.activity.ComponentActivity +import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Immutable import androidx.compose.ui.focus.FocusRequester import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import ua.acclorite.book_story.domain.model.NullableBook +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.util.OnNavigate import ua.acclorite.book_story.domain.util.Selected import java.io.File @@ -16,22 +18,28 @@ import java.io.File sealed class BrowseEvent { data class OnStoragePermissionRequest( val activity: ComponentActivity, - val storagePermissionState: PermissionState, - val hideErrorMessage: () -> Unit + val storagePermissionState: PermissionState ) : BrowseEvent() data class OnStoragePermissionDismiss( - val permissionState: PermissionState, - val showErrorMessage: () -> Unit + val permissionState: PermissionState ) : BrowseEvent() data object OnRefreshList : BrowseEvent() data class OnPermissionCheck( - val permissionState: PermissionState, - val hideErrorMessage: () -> Unit + val permissionState: PermissionState + ) : BrowseEvent() + + data class OnSelectFile( + val includedFileFormats: List, + val file: SelectableFile + ) : BrowseEvent() + + data class OnSelectFiles( + val includedFileFormats: List, + val files: List ) : BrowseEvent() - data class OnSelectFile(val file: Pair) : BrowseEvent() data class OnSelectBook(val book: Pair) : BrowseEvent() data object OnSearchShowHide : BrowseEvent() data class OnRequestFocus(val focusRequester: FocusRequester) : BrowseEvent() @@ -50,4 +58,11 @@ sealed class BrowseEvent { data object OnLoadList : BrowseEvent() data object OnUpdateScrollOffset : BrowseEvent() + data class OnChangeDirectory(val directory: File, val savePreviousDirectory: Boolean) : + BrowseEvent() + + data object OnGoBackDirectory : BrowseEvent() + data object OnShowHideFilterBottomSheet : BrowseEvent() + data class OnScrollToFilterPage(val page: Int, val pagerState: PagerState?) : BrowseEvent() + data class OnUpdateFavoriteDirectory(val path: String) : BrowseEvent() } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseState.kt index b6b0a4fa..ec7cce7f 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseState.kt @@ -1,18 +1,29 @@ package ua.acclorite.book_story.presentation.screens.browse.data +import android.os.Environment import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Immutable import ua.acclorite.book_story.domain.model.NullableBook +import ua.acclorite.book_story.domain.model.SelectableFile import ua.acclorite.book_story.domain.util.Selected import java.io.File @Immutable data class BrowseState( - val selectableFiles: List> = emptyList(), - val listState: LazyListState = LazyListState(0, 0), + val selectableFiles: List = emptyList(), + val listState: LazyListState = LazyListState(), + val gridState: LazyGridState = LazyGridState(), + + val selectedDirectory: File = Environment.getExternalStorageDirectory(), + val previousDirectory: File? = null, + val inNestedDirectory: Boolean = false, + val showFilterBottomSheet: Boolean = false, + val currentPage: Int = 1, val isLoading: Boolean = true, val isRefreshing: Boolean = false, + val isError: Boolean = false, val requestPermissionDialog: Boolean = false, val selectedItemsCount: Int = 0, @@ -20,6 +31,7 @@ data class BrowseState( val showSearch: Boolean = false, val searchQuery: String = "", + val hasSearched: Boolean = false, val hasFocused: Boolean = false, val showAddingDialog: Boolean = false, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseViewModel.kt index 51dd1173..71e4eb5d 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/data/BrowseViewModel.kt @@ -1,3 +1,5 @@ +@file:Suppress("LABEL_NAME_CLASH") + package ua.acclorite.book_story.presentation.screens.browse.data import android.content.Intent @@ -6,6 +8,7 @@ import android.os.Build import android.os.Environment import android.provider.Settings import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -21,35 +24,43 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield import ua.acclorite.book_story.domain.model.NullableBook -import ua.acclorite.book_story.domain.use_case.GetBooksFromFiles +import ua.acclorite.book_story.domain.model.SelectableFile +import ua.acclorite.book_story.domain.use_case.GetBookFromFile import ua.acclorite.book_story.domain.use_case.GetFilesFromDevice import ua.acclorite.book_story.domain.use_case.InsertBook -import ua.acclorite.book_story.domain.util.Resource +import ua.acclorite.book_story.domain.use_case.UpdateFavoriteDirectory +import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.data.launchActivity +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseFilesStructure +import ua.acclorite.book_story.presentation.screens.settings.nested.browse.data.BrowseSortOrder import javax.inject.Inject @OptIn(ExperimentalPermissionsApi::class) @HiltViewModel class BrowseViewModel @Inject constructor( - private val getBooksFromFiles: GetBooksFromFiles, + private val getBookFromFile: GetBookFromFile, private val getFilesFromDevice: GetFilesFromDevice, - private val insertBook: InsertBook + private val insertBook: InsertBook, + private val updateFavoriteDirectory: UpdateFavoriteDirectory, ) : ViewModel() { private val _state = MutableStateFlow(BrowseState()) val state = _state.asStateFlow() - private var job: Job? = null - private var job2: Job? = null - private var job3: Job? = null + private var searchQueryJob: Job? = null + private var refreshListJob: Job? = null + private var getBooksJob: Job? = null private var storagePermissionJob: Job? = null + private var changeDirectoryJob: Job? = null init { viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy( - isLoading = true + isLoading = true, + selectedDirectory = Environment.getExternalStorageDirectory(), + inNestedDirectory = false ) } getFilesFromDownloads() @@ -70,10 +81,10 @@ class BrowseViewModel @Inject constructor( if (isPermissionGranted) { _state.update { it.copy( - requestPermissionDialog = false + requestPermissionDialog = false, + isError = false ) } - event.hideErrorMessage() onEvent(BrowseEvent.OnRefreshList) return } @@ -130,10 +141,10 @@ class BrowseViewModel @Inject constructor( _state.update { it.copy( - requestPermissionDialog = false + requestPermissionDialog = false, + isError = false ) } - event.hideErrorMessage() onEvent(BrowseEvent.OnRefreshList) break } @@ -158,19 +169,24 @@ class BrowseViewModel @Inject constructor( getFilesFromDownloads() } } else { - event.showErrorMessage() + _state.update { + it.copy( + isError = true + ) + } } } is BrowseEvent.OnRefreshList -> { - job2?.cancel() - job2 = viewModelScope.launch(Dispatchers.IO) { + refreshListJob?.cancel() + refreshListJob = viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy( isRefreshing = true, hasSelectedItems = false, showSearch = false, - listState = LazyListState(0, 0) + listState = LazyListState(), + gridState = LazyGridState(), ) } yield() @@ -197,30 +213,94 @@ class BrowseViewModel @Inject constructor( _state.update { it.copy( - requestPermissionDialog = true + requestPermissionDialog = true, + isError = false ) } - event.hideErrorMessage() } } is BrowseEvent.OnSelectFile -> { viewModelScope.launch(Dispatchers.IO) { - val editedList = _state.value.selectableFiles.map { - if (event.file.first.path == it.first.path) { - it.copy( - second = !it.second - ) - } else { - it + val editedList = _state.value.selectableFiles.map { file -> + when (event.file.isDirectory) { + false -> { + if (event.file.fileOrDirectory.path == file.fileOrDirectory.path) { + file.copy( + isSelected = !file.isSelected + ) + } else { + file + } + } + + true -> { + if ( + file.fileOrDirectory.path.startsWith( + event.file.fileOrDirectory.path + ) && event.includedFileFormats.run { + if (isEmpty()) return@run true + any { + file.fileOrDirectory.path.endsWith( + it, ignoreCase = true + ) || file.isDirectory + } + } + ) { + file.copy( + isSelected = !event.file.isSelected + ) + } else { + file + } + } } } _state.update { it.copy( selectableFiles = editedList, - selectedItemsCount = editedList.filter { file -> file.second }.size, - hasSelectedItems = editedList.any { file -> file.second } + selectedItemsCount = editedList.filter { file -> + file.isSelected && !file.isDirectory + }.size, + hasSelectedItems = editedList.any { file -> + file.isSelected && !file.isDirectory + } + ) + } + } + } + + is BrowseEvent.OnSelectFiles -> { + viewModelScope.launch(Dispatchers.IO) { + val editedList = _state.value.selectableFiles.map { file -> + if ( + event.files.any { + file.fileOrDirectory.path.startsWith(it.fileOrDirectory.path) + } && event.includedFileFormats.run { + if (isEmpty()) return@run true + any { + file.fileOrDirectory.path.endsWith( + it, ignoreCase = true + ) || file.isDirectory + } + } + ) { + file.copy(isSelected = true) + } else { + file + } + } + + _state.update { + it.copy( + selectableFiles = editedList, + selectedItemsCount = editedList.filter { file -> + file.isSelected && !file.isDirectory + }.size, + hasSelectedItems = editedList.any { file -> + file.isSelected && !file.isDirectory + } ) } } @@ -262,6 +342,7 @@ class BrowseViewModel @Inject constructor( _state.update { it.copy( searchQuery = "", + hasSearched = false, hasFocused = false ) } @@ -289,7 +370,11 @@ class BrowseViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy( - selectableFiles = it.selectableFiles.map { file -> file.copy(second = false) }, + selectableFiles = it.selectableFiles.map { file -> + file.copy( + isSelected = false + ) + }, hasSelectedItems = false ) } @@ -302,8 +387,8 @@ class BrowseViewModel @Inject constructor( searchQuery = event.query ) } - job?.cancel() - job = viewModelScope.launch(Dispatchers.IO) { + searchQueryJob?.cancel() + searchQueryJob = viewModelScope.launch(Dispatchers.IO) { delay(500) yield() onEvent(BrowseEvent.OnSearch) @@ -322,7 +407,7 @@ class BrowseViewModel @Inject constructor( showAddingDialog = false ) } - job3?.cancel(null) + getBooksJob?.cancel() } is BrowseEvent.OnAddingDialogRequest -> { @@ -336,7 +421,7 @@ class BrowseViewModel @Inject constructor( } is BrowseEvent.OnGetBooksFromFiles -> { - job3 = viewModelScope.launch(Dispatchers.IO) { + getBooksJob = viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy( isBooksLoading = true @@ -347,15 +432,11 @@ class BrowseViewModel @Inject constructor( val books = mutableListOf() _state.value.selectableFiles - .filter { it.second } - .map { it.first } + .filter { it.isSelected && !it.isDirectory } + .map { it.fileOrDirectory } .forEach { yield() - books.add( - getBooksFromFiles.execute( - listOf(it) - ).first() - ) + books.add(getBookFromFile.execute(it)) } yield() @@ -422,7 +503,8 @@ class BrowseViewModel @Inject constructor( it.copy( isLoading = true, hasSelectedItems = false, - listState = LazyListState(0, 0) + listState = LazyListState(), + gridState = LazyGridState() ) } getFilesFromDownloads() @@ -435,29 +517,201 @@ class BrowseViewModel @Inject constructor( listState = LazyListState( it.listState.firstVisibleItemIndex, 0 + ), + gridState = LazyGridState( + it.gridState.firstVisibleItemIndex, + 0 ) ) } } + + is BrowseEvent.OnChangeDirectory -> { + viewModelScope.launch { + changeDirectoryJob?.cancel() + searchQueryJob?.cancel() + + changeDirectoryJob = launch(Dispatchers.IO) { + yield() + _state.update { + it.copy( + listState = LazyListState(), + gridState = LazyGridState(), + selectedDirectory = event.directory, + previousDirectory = if (event.savePreviousDirectory) it.selectedDirectory + else event.directory.parentFile, + inNestedDirectory = event.directory != Environment.getExternalStorageDirectory() + ) + } + } + } + } + + is BrowseEvent.OnGoBackDirectory -> { + onEvent( + BrowseEvent.OnChangeDirectory( + _state.value.previousDirectory + ?: Environment.getExternalStorageDirectory(), + savePreviousDirectory = false + ) + ) + } + + is BrowseEvent.OnShowHideFilterBottomSheet -> { + viewModelScope.launch(Dispatchers.IO) { + _state.update { + it.copy( + showFilterBottomSheet = !it.showFilterBottomSheet + ) + } + } + } + + is BrowseEvent.OnScrollToFilterPage -> { + viewModelScope.launch { + _state.update { + event.pagerState?.scrollToPage(event.page) + + it.copy( + currentPage = event.page + ) + } + } + } + + is BrowseEvent.OnUpdateFavoriteDirectory -> { + viewModelScope.launch { + _state.update { + it.copy(isLoading = true) + } + updateFavoriteDirectory.execute(event.path) + getFilesFromDownloads() + } + } } } + fun filterList(mainState: MainState): List { + fun thenCompareBy( + selector: (T) -> Comparable<*>? + ): Comparator { + return if (mainState.browseSortOrderDescending!!) { + compareByDescending(selector) + } else { + compareBy(selector) + } + } + + fun List.filterFiles(): List { + 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 { + when (mainState.browsePinFavoriteDirectories!!) { + true -> it.isFavorite + false -> true + } + }.then( + compareByDescending { + when (mainState.browseSortOrder!! != BrowseSortOrder.FILE_TYPE) { + true -> it.isDirectory + false -> true + } + } + ).then( + thenCompareBy { + when (mainState.browseSortOrder!!) { + BrowseSortOrder.NAME -> { + it.fileOrDirectory.name.lowercase().trim() + } + + BrowseSortOrder.FILE_TYPE -> { + it.isDirectory + } + + BrowseSortOrder.FILE_FORMAT -> { + it.fileOrDirectory.extension + } + + BrowseSortOrder.FILE_SIZE -> { + it.fileOrDirectory.length() + } + + BrowseSortOrder.LAST_MODIFIED -> { + it.fileOrDirectory.lastModified() + } + } + } + ) + ) + } + private suspend fun getFilesFromDownloads( query: String = if (_state.value.showSearch) _state.value.searchQuery else "" ) { - getFilesFromDevice.execute(query).collect { result -> - when (result) { - is Resource.Success -> { - _state.update { - it.copy( - selectableFiles = result.data?.map { book -> Pair(book, false) } - ?: emptyList(), - isLoading = false - ) - } - } + getFilesFromDevice.execute(query).apply { + yield() + _state.update { + it.copy( + selectableFiles = this, + selectedItemsCount = 0, + hasSearched = query.isNotBlank(), + hasSelectedItems = false, + isLoading = false + ) + } + } + } - is Resource.Error -> Unit + fun clearViewModel() { + viewModelScope.launch(Dispatchers.Main) { + _state.update { + it.copy(isError = false) } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/SettingsScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/SettingsScreen.kt index 1d5799f4..fe84b415 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/SettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.DisplaySettings +import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.Palette import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar @@ -113,6 +114,18 @@ private fun SettingsScreen( } } + item { + SettingsCategoryItem( + icon = Icons.Default.Explore, + text = stringResource(id = R.string.browse_settings), + description = stringResource(id = R.string.browse_settings_desc) + ) { + onNavigate { + navigate(Screen.Settings.BrowseSettings) + } + } + } + item { Spacer(modifier = Modifier.height(48.dp)) } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/BrowseSettings.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/BrowseSettings.kt new file mode 100644 index 00000000..be6975f9 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/BrowseSettings.kt @@ -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, + 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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/BrowseSettingsCategory.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/BrowseSettingsCategory.kt new file mode 100644 index 00000000..380a64bd --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/BrowseSettingsCategory.kt @@ -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, + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilesStructureSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilesStructureSetting.kt new file mode 100644 index 00000000..26c20bdc --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilesStructureSetting.kt @@ -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, + 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 + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilterSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilterSetting.kt new file mode 100644 index 00000000..b6f7e580 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseFilterSetting.kt @@ -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, + 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseGridSizeSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseGridSizeSetting.kt new file mode 100644 index 00000000..9acea536 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseGridSizeSetting.kt @@ -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, + 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) + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseLayoutSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseLayoutSetting.kt new file mode 100644 index 00000000..54015f5b --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseLayoutSetting.kt @@ -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, + 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 + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowsePinFavoriteDirectoriesSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowsePinFavoriteDirectoriesSetting.kt new file mode 100644 index 00000000..f0fc525d --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowsePinFavoriteDirectoriesSetting.kt @@ -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, + 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!! + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseSortOrderSetting.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseSortOrderSetting.kt new file mode 100644 index 00000000..39bc59c4 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/settings/BrowseSortOrderSetting.kt @@ -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, + 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseFilterSubcategory.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseFilterSubcategory.kt new file mode 100644 index 00000000..04bd7e99 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseFilterSubcategory.kt @@ -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, + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseGeneralSubcategory.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseGeneralSubcategory.kt new file mode 100644 index 00000000..d32bf3b3 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseGeneralSubcategory.kt @@ -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, + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseSortSubcategory.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseSortSubcategory.kt new file mode 100644 index 00000000..a46914a4 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/components/subcategories/BrowseSortSubcategory.kt @@ -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, + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseFilesStructure.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseFilesStructure.kt new file mode 100644 index 00000000..97c25012 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseFilesStructure.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseLayout.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseLayout.kt new file mode 100644 index 00000000..c3f051f7 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseLayout.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseSortOrder.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseSortOrder.kt new file mode 100644 index 00000000..db1462f2 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/browse/data/BrowseSortOrder.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cdc413bf..741992d2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -187,6 +187,8 @@ Кинуто Головні Кольори + Фільтр + Сортування Головні @@ -195,6 +197,8 @@ Тема, кольори Читач Шрифт, текст, перекладач + Огляд + Головні, фільтр, сортування Шрифт @@ -202,6 +206,9 @@ Перекладач Налаштування теми Кольори + Головні + Фільтр + Сотрування Мова застосунка @@ -225,6 +232,13 @@ Двічі клацніть на абзац, щоб перекласти його Швидка зміна пресета кольорів Проведіть вліво або вправо, щоб змінити пресет кольорів + Структура файлів + Режим відображення + Кількість комірок + Закріпити улюблені директорії + + Закріпіть ваші улюблені директорії зверху екрана + Червоний @@ -251,6 +265,25 @@ Похилий Нормальний + + Всі файли + Директорії + + + Список + Сітка + + + Авто + кожен рядок + + + За алфавітом + Формат файла + Тип файла + Остання зміна + Розмір файла + Меркурій Нептун @@ -293,6 +326,9 @@ Розмір файлу Невідомо + + Внутрішнє Сховище + Завантаження… Приємного читання! @@ -512,6 +548,10 @@ Налаштування читача Анімація верхньої панелі Файл + Тека + Улюблена тека + Порядок сортування + Стрілка шляху Галочка Випадаючий список Обкладенка не знайдена @@ -520,6 +560,7 @@ Пошук Очистити обрані предмети Додати файли + Вибрати всі файли Вийти з пошуку Видалити всю історію Перемістити обрані книги @@ -545,5 +586,6 @@ GitHub Проєкт GitHub Профіль Тягнути + Фільтр \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64cebb6b..5a82c989 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,6 +206,8 @@ Dropped General Colors + Filter + Sort General @@ -214,6 +216,8 @@ Theme, colors Reader Font, text, translator + Browse + General, filter, sort Font @@ -221,6 +225,9 @@ Translator Theme preferences Colors + General + Filter + Sort App language @@ -244,6 +251,13 @@ Double click on paragraph to translate it Fast color preset change Swipe left or right to change color preset + Files structure + Display mode + Grid size + Pin favorite directories + + Pin your favorite directories to the top of the screen + Red @@ -270,6 +284,25 @@ Italic Normal + + All files + Directories + + + List + Grid + + + Auto + per row + + + Alphabetically + File format + File type + Last modified + File size + Mercury Neptune @@ -312,6 +345,9 @@ File size Unknown + + Internal Storage + Loading… Happy reading! @@ -530,6 +566,10 @@ Reader settings Top app bar animation File + Directory + Favorite directory + Sort order + Path arrow Checkbox Dropdown Cover image not found @@ -538,6 +578,7 @@ Search Clear selected items Add files + Select all files Exit search Delete whole history Move selected books @@ -565,5 +606,6 @@ GitHub Project GitHub Profile Drag + Filter \ No newline at end of file