diff --git a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/BackupWorker.kt b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/BackupWorker.kt index 7541718381..0e6ac10387 100644 --- a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/BackupWorker.kt +++ b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/BackupWorker.kt @@ -72,6 +72,8 @@ class BackupWorker(appContext: Context, params: WorkerParameters) : CoroutineWor private val chaptersRepository by instance() private val extensionRepoRepository by instance() private val backupRepository by instance() + private val categoriesRepository by instance() + private val novelCategoriesRepository by instance() override val notificationManager: NotificationManagerCompat by notificationManager() @@ -123,6 +125,15 @@ class BackupWorker(appContext: Context, params: WorkerParameters) : CoroutineWor return emptyList() } + private suspend fun getBackupCategories(): Map { + return categoriesRepository.getCategories().associate { + it.id!! to BackupCategoryEntity( + it.name, + it.order + ) + } + } + @Throws(IOException::class) override suspend fun doWork(): Result { @@ -135,6 +146,7 @@ class BackupWorker(appContext: Context, params: WorkerParameters) : CoroutineWor lateinit var novelsToChapters: List>> lateinit var extensions: List + lateinit var categories: Map /* Run to isolate the variable 'novels' so it can be trashed, hopefully saving memory @@ -170,6 +182,16 @@ class BackupWorker(appContext: Context, params: WorkerParameters) : CoroutineWor } }.distinct() + // Categories each novel requires + categories = try { + getBackupCategories() + } catch (e: SQLiteException) { + ACRA.errorReporter.handleSilentException(e) + e.printStackTrace() + emptyMap() + } + + true } System.gc() // please clean up @@ -217,16 +239,21 @@ class BackupWorker(appContext: Context, params: WorkerParameters) : CoroutineWor ) } ?: BackupNovelSettingEntity() + val novelCategories = novelCategoriesRepository.getNovelCategoriesFromNovel(novel.id!!) + .map { categories[it.categoryID]!!.order } + BackupNovelEntity( novel.url, novel.title, novel.imageURL, chapters, - settings = bSettings + settings = bSettings, + categories = novelCategories ) } ) - } + }, + categories = categories.values.toList() ) logI("Encoding to json") diff --git a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/NovelUpdateWorker.kt b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/NovelUpdateWorker.kt index feb0fc4a84..8e43468aba 100644 --- a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/NovelUpdateWorker.kt +++ b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/NovelUpdateWorker.kt @@ -30,7 +30,7 @@ import app.shosetsu.android.common.consts.Notifications.ID_CHAPTER_UPDATE import app.shosetsu.android.common.consts.WorkerTags.UPDATE_WORK_ID import app.shosetsu.android.common.ext.* import app.shosetsu.android.domain.model.local.ChapterEntity -import app.shosetsu.android.domain.model.local.NovelEntity +import app.shosetsu.android.domain.model.local.LibraryNovelEntity import app.shosetsu.android.domain.repository.base.INovelsRepository import app.shosetsu.android.domain.repository.base.ISettingsRepository import app.shosetsu.android.domain.usecases.StartDownloadWorkerAfterUpdateUseCase @@ -41,6 +41,7 @@ import app.shosetsu.lib.exceptions.HTTPException import coil.imageLoader import coil.request.ImageRequest import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.android.closestDI @@ -118,6 +119,12 @@ class NovelUpdateWorker( private suspend fun onlyUpdateOngoing(): Boolean = iSettingsRepository.getBoolean(OnlyUpdateOngoingNovels) + private suspend fun includedCategoriesInLibraryUpdate(): List = + iSettingsRepository.getStringSet(IncludeCategoriesInUpdate).map(String::toInt) + + private suspend fun excludedCategoriesInLibraryUpdate(): List = + iSettingsRepository.getStringSet(ExcludedCategoriesInUpdate).map(String::toInt) + private suspend fun downloadOnUpdate(): Boolean = iSettingsRepository.getBoolean(DownloadNewNovelChapters) @@ -141,17 +148,39 @@ class NovelUpdateWorker( } /** Count of novels that have been updated */ - val updateNovels = arrayListOf() + val updateNovels = arrayListOf() /** Collect updated chapters to be used */ val updatedChapters = arrayListOf() - iNovelsRepository.loadBookmarkedNovelEntities().let { list -> + iNovelsRepository.loadLibraryNovelEntities().first().let { list -> + val categoryID = inputData.getInt(KEY_CATEGORY, -1) + if (categoryID >= 0) { + list.filter { it.category == categoryID } + } else list + }.let { list -> if (onlyUpdateOngoing()) list.filter { it.status != Novel.Status.COMPLETED } else list }.let { list -> - list.sortedBy { it.title } + val includedCategories = includedCategoriesInLibraryUpdate() + val includedNovels = if (includedCategories.isNotEmpty()) { + list.filter { it.category in includedCategories } + } else { + list + } + + val excludedCategories = excludedCategoriesInLibraryUpdate() + val excludedNovels = if (excludedCategories.isNotEmpty()) { + list.filter { it.category in excludedCategories }.map { it.id }.toSet() + } else { + emptySet() + } + + includedNovels.filterNot { it.id in excludedNovels } + }.let { list -> + list.distinctBy { it.id } + .sortedBy { it.title } }.let { novels -> var progress = 0 @@ -178,12 +207,12 @@ class NovelUpdateWorker( } val it = try { - loadRemoteNovelUseCase(nE, true) + loadRemoteNovelUseCase(nE.id, true) } catch (e: LuaError) { logE("Failed to load novel: $nE", e) notify( "${e.message}", - 10000 + nE.id!! + 10000 + nE.id ) { setContentTitle( getString( @@ -202,7 +231,7 @@ class NovelUpdateWorker( logE("Failed to load novel: $nE", e) notify( "${e.message}", - 10000 + nE.id!! + 10000 + nE.id ) { setContentTitle( getString( @@ -221,7 +250,7 @@ class NovelUpdateWorker( logE("Failed to load novel: $nE", e) notify( "${e.message}", - 10000 + nE.id!! + 10000 + nE.id ) { setContentTitle( getString( @@ -244,7 +273,7 @@ class NovelUpdateWorker( logE("Failed to load novel: $nE", e) notify( "${e.message}", - 10000 + nE.id!! + 10000 + nE.id ) { setContentTitle( getString( @@ -260,7 +289,7 @@ class NovelUpdateWorker( addReportErrorAction( applicationContext, - 10000 + nE.id!!, + 10000 + nE.id, e ) } @@ -307,7 +336,7 @@ class NovelUpdateWorker( chapterSize, chapterSize ), - 10000 + novel.id!! + 10000 + novel.id ) { setContentTitle( getString( @@ -324,7 +353,7 @@ class NovelUpdateWorker( if (firstChapterId != null) { addOpenReader( - novel.id ?: return@notify, + novel.id, firstChapterId ) setAutoCancel(true) @@ -422,7 +451,7 @@ class NovelUpdateWorker( if (SDK_INT >= VERSION_CODES.M) setRequiresDeviceIdle(updateOnlyIdle()) }.build() - ).build() + ).setInputData(data).build() ) workerManager.getWorkInfosForUniqueWork(UPDATE_WORK_ID).await()[0].let { Log.d(logID(), "State ${it.state}") @@ -442,6 +471,6 @@ class NovelUpdateWorker( const val KEY_CHAPTERS: String = "Novels" const val KEY_NOVELS: Int = 0x00 - const val KEY_CATEGORY: Int = 0x01 + const val KEY_CATEGORY: String = "category" } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/RestoreBackupWorker.kt b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/RestoreBackupWorker.kt index 06bfcf36b5..bc5ce2a24d 100644 --- a/android/src/main/java/app/shosetsu/android/backend/workers/onetime/RestoreBackupWorker.kt +++ b/android/src/main/java/app/shosetsu/android/backend/workers/onetime/RestoreBackupWorker.kt @@ -25,6 +25,7 @@ import app.shosetsu.android.common.utils.backupJSON import app.shosetsu.android.domain.model.local.* import app.shosetsu.android.domain.model.local.backup.* import app.shosetsu.android.domain.repository.base.* +import app.shosetsu.android.domain.usecases.AddCategoryUseCase import app.shosetsu.android.domain.usecases.InstallExtensionUseCase import app.shosetsu.android.domain.usecases.StartRepositoryUpdateManagerUseCase import app.shosetsu.lib.IExtension @@ -82,6 +83,9 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou private val novelsSettingsRepo by instance() private val chaptersRepo by instance() private val backupUriRepo by instance() + private val categoriesRepo by instance() + private val novelCategoriesRepo by instance() + private val addCategoryUseCase by instance() override val baseNotificationBuilder: NotificationCompat.Builder get() = notificationBuilder(applicationContext, Notifications.CHANNEL_BACKUP) .setSubText(getString(R.string.restore_notification_subtitle)) @@ -186,6 +190,17 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou unGZip(decodedBytes).use { stream -> val backup = backupJSON.decodeFromStream(stream) + notify("Adding categories") + val currentCategories = categoriesRepo.getCategories() + val categoryOrderToCategoryIds = backup.categories.sortedBy { it.order }.associate { backupCategory -> + val currentCategory = currentCategories.find { backupCategory.name == it.name } + backupCategory.order to if (currentCategory != null) { + currentCategory.id!! + } else { + addCategoryUseCase(backupCategory.name) + } + } + notify("Adding repositories") // Adds the repositories backup.repos.forEach { (url, name) -> @@ -212,7 +227,7 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou val repoNovels: List = novelsRepo.loadNovels() val extensions = extensionsRepo.loadRepositoryExtensions() - backup.extensions.forEach { restoreExtension(extensions, repoNovels, it) } + backup.extensions.forEach { restoreExtension(extensions, repoNovels, it, categoryOrderToCategoryIds) } } @@ -230,7 +245,8 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou private suspend fun restoreExtension( extensions: List, repoNovels: List, - backupExtensionEntity: BackupExtensionEntity + backupExtensionEntity: BackupExtensionEntity, + categoryOrderToCategoryIds: Map ) { val extensionID = backupExtensionEntity.id val backupNovels = backupExtensionEntity.novels @@ -253,7 +269,8 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou iExt, extensionID, novelEntity, - repoNovels + repoNovels, + categoryOrderToCategoryIds ) } catch (e: Exception) { e.printStackTrace() @@ -266,7 +283,8 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou iExt: IExtension, extensionID: Int, backupNovelEntity: BackupNovelEntity, - repoNovels: List + repoNovels: List, + categoryOrderToCategoryIds: Map ) { logV("$extensionID, ${backupNovelEntity.url}") // Use a single memory location for the bitmap @@ -486,6 +504,42 @@ class RestoreBackupWorker(appContext: Context, params: WorkerParameters) : Corou ) } + notify(R.string.restore_notification_content_categories_restore) { + setContentTitle(name) + } + val novelCategories = try { + novelCategoriesRepo.getNovelCategoriesFromNovel(targetNovelID) + } catch (e: Exception) {// TODO specify + logE("Failed to load novel categories") + ACRA.errorReporter.handleSilentException(e) + return + } + + if (novelCategories.isEmpty()) { + logI("Inserting novel categories") + novelCategoriesRepo.setNovelCategories( + backupNovelEntity.categories.map { + NovelCategoryEntity( + targetNovelID, + categoryOrderToCategoryIds[it]!! + ) + } + ) + } else { + val existingNovelCategories = novelCategories.map { it.categoryID } + novelCategoriesRepo.setNovelCategories( + backupNovelEntity.categories.mapNotNull { + val category = categoryOrderToCategoryIds[it] + if (category == null || it in existingNovelCategories) + return@mapNotNull null + + NovelCategoryEntity( + targetNovelID, + categoryOrderToCategoryIds[it]!! + ) + } + ) + } loadImageJob.join() // Finish the image loading job diff --git a/android/src/main/java/app/shosetsu/android/common/SettingKey.kt b/android/src/main/java/app/shosetsu/android/common/SettingKey.kt index 4b451a8740..3978a65040 100644 --- a/android/src/main/java/app/shosetsu/android/common/SettingKey.kt +++ b/android/src/main/java/app/shosetsu/android/common/SettingKey.kt @@ -176,6 +176,12 @@ sealed class SettingKey(val name: String, val default: T) { object OnlyUpdateOngoingNovels : BooleanKey("onlyUpdateOngoing", false) object UpdateNovelsOnStartup : BooleanKey("updateOnStartup", true) + object IncludeCategoriesInUpdate : StringSetKey("includedCategoriesInUpdate", emptySet()) + object ExcludedCategoriesInUpdate : StringSetKey("excludedCategoriesInUpdate", emptySet()) + + object IncludeCategoriesToDownload : StringSetKey("includedCategoriesToDownload", emptySet()) + object ExcludedCategoriesToDownload : StringSetKey("excludedCategoriesToDownload", emptySet()) + object NovelUpdateCycle : IntKey("updateCycle", 12) object NovelUpdateOnLowStorage : BooleanKey("updateLowStorage", true) object NovelUpdateOnLowBattery : BooleanKey("updateLowBattery", true) diff --git a/android/src/main/java/app/shosetsu/android/common/consts/Constants.kt b/android/src/main/java/app/shosetsu/android/common/consts/Constants.kt index b68936b684..955a171779 100644 --- a/android/src/main/java/app/shosetsu/android/common/consts/Constants.kt +++ b/android/src/main/java/app/shosetsu/android/common/consts/Constants.kt @@ -51,7 +51,7 @@ const val APK_MIME = "application/vnd.android.package-archive" /** * The version of backups this build of shosetsu supports */ -const val VERSION_BACKUP: String = "1.0.0" +const val VERSION_BACKUP: String = "1.1.0" const val BACKUP_FILE_EXTENSION = "sbk" const val REPOSITORY_HELP_URL = "https://shosetsu.app/help/guides/repositories/" const val URL_WEBSITE = "https://shosetsu.app" diff --git a/android/src/main/java/app/shosetsu/android/datasource/local/database/DBDataSourceModule.kt b/android/src/main/java/app/shosetsu/android/datasource/local/database/DBDataSourceModule.kt index 953ed2a7f1..927b88cb41 100644 --- a/android/src/main/java/app/shosetsu/android/datasource/local/database/DBDataSourceModule.kt +++ b/android/src/main/java/app/shosetsu/android/datasource/local/database/DBDataSourceModule.kt @@ -28,6 +28,7 @@ import org.kodein.di.singleton * 01 / 01 / 2021 */ val dbDataSourceModule = DI.Module("database_data_source") { + bind() with singleton { DBCategoriesDataSource(instance()) } bind() with singleton { DBChaptersDataSource(instance()) } bind() with singleton { DBDownloadsDataSource(instance()) } @@ -45,6 +46,8 @@ val dbDataSourceModule = DI.Module("database_data_source") { bind() with singleton { DBExtLibDataSource(instance()) } + bind() with singleton { DBNovelCategoriesDataSource(instance()) } + bind() with singleton { DBNovelsDataSource(instance()) } bind() with singleton { DBExtRepoDataSource(instance()) } diff --git a/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBCategoriesDataSource.kt b/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBCategoriesDataSource.kt new file mode 100644 index 0000000000..b716b4cf8b --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBCategoriesDataSource.kt @@ -0,0 +1,64 @@ +package app.shosetsu.android.datasource.local.database.base + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.CategoryEntity +import kotlinx.coroutines.flow.Flow + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + + + + +/** + * shosetsu + * 08 / 08 / 2022 + */ +interface IDBCategoriesDataSource { + + fun getCategoriesFlow(): Flow> + + @Throws(SQLiteException::class) + suspend fun getCategories(): List + + @Throws(SQLiteException::class) + suspend fun addCategory(categoryEntity: CategoryEntity): Long + + /** + * If the category already exists + */ + @Throws(SQLiteException::class) + suspend fun categoryExists(name: String): Boolean + + /** + * Get the next [CategoryEntity.order] variable + */ + @Throws(SQLiteException::class) + suspend fun getNextCategoryOrder(): Int + + /** + * Delete a [CategoryEntity] from the database + */ + @Throws(SQLiteException::class) + suspend fun deleteCategory(categoryEntity: CategoryEntity) + + /** + * Update a list of [CategoryEntity]s + */ + @Throws(SQLiteException::class) + suspend fun updateCategories(categories: List) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBNovelCategoriesDataSource.kt b/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBNovelCategoriesDataSource.kt new file mode 100644 index 0000000000..48ccd47493 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/datasource/local/database/base/IDBNovelCategoriesDataSource.kt @@ -0,0 +1,66 @@ +package app.shosetsu.android.datasource.local.database.base + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import kotlinx.coroutines.flow.Flow + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + + + + +/** + * shosetsu + * 08 / 08 / 2022 + */ +interface IDBNovelCategoriesDataSource { + + /** + * Loads all [NovelCategoryEntity]s from a novel id with a flow + */ + fun getNovelCategoriesFromNovelFlow(novelID: Int): Flow> + + /** + * Loads all [NovelCategoryEntity]s from a novel id + */ + @Throws(SQLiteException::class) + suspend fun getNovelCategoriesFromNovel(novelID: Int): List + + /** + * Loads all [NovelCategoryEntity]s from a category id with a flow + */ + fun getNovelCategoriesFromCategoryFlow(categoryID: Int): Flow> + + /** + * Set the categories for a novel + */ + @Throws(SQLiteException::class) + suspend fun setNovelCategories(entities: List): Array + + /** + * Delete the categories for a novel + */ + @Throws(SQLiteException::class) + suspend fun deleteNovelCategories(novelID: Int) + + /** + * Delete the categories for multiple novels + */ + @Throws(SQLiteException::class) + suspend fun deleteNovelsCategories(novelIDs: List) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBCategoriesDataSource.kt b/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBCategoriesDataSource.kt new file mode 100644 index 0000000000..f3b2079490 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBCategoriesDataSource.kt @@ -0,0 +1,90 @@ +package app.shosetsu.android.datasource.local.database.impl + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.common.ext.onIO +import app.shosetsu.android.datasource.local.database.base.IDBCategoriesDataSource +import app.shosetsu.android.domain.model.database.DBCategoryEntity +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.dto.convertList +import app.shosetsu.android.providers.database.dao.CategoriesDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * Shosetsu + * 10 / May / 2020 + * + * @author github.com/doomsdayrs + */ +class DBCategoriesDataSource( + private val categoriesDao: CategoriesDao, +) : IDBCategoriesDataSource { + + override fun getCategoriesFlow(): Flow> = + categoriesDao.getCategoriesFlow().map { it.convertList() } + + @Throws(SQLiteException::class) + override suspend fun getCategories(): List = + onIO { categoriesDao.getCategories().convertList() } + + /** + * Add category to the database + */ + @Throws(SQLiteException::class) + override suspend fun addCategory(categoryEntity: CategoryEntity) = + onIO { categoriesDao.insertAbort(categoryEntity.toDB()) } + + /** + * If the category already exists + */ + @Throws(SQLiteException::class) + override suspend fun categoryExists(name: String): Boolean = + onIO { categoriesDao.categoryExists(name) != 0 } + + /** + * Get the next [CategoryEntity.order] variable + */ + @Throws(SQLiteException::class) + override suspend fun getNextCategoryOrder(): Int = + onIO { categoriesDao.getNextCategoryOrder() } + + /** + * Delete a [CategoryEntity] from the database + */ + @Throws(SQLiteException::class) + override suspend fun deleteCategory(categoryEntity: CategoryEntity) = + onIO { categoriesDao.delete(categoryEntity.toDB()) } + + /** + * Update a list of [CategoryEntity]s + */ + @Throws(SQLiteException::class) + override suspend fun updateCategories(categories: List) = + onIO { categoriesDao.update(categories.toDB()) } + + fun CategoryEntity.toDB() = + DBCategoryEntity( + id = id, + name = name, + order = order + ) + + fun List.toDB() = map { it.toDB() } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBNovelCategoriesDataSource.kt b/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBNovelCategoriesDataSource.kt new file mode 100644 index 0000000000..6244fcb5ea --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/datasource/local/database/impl/DBNovelCategoriesDataSource.kt @@ -0,0 +1,65 @@ +package app.shosetsu.android.datasource.local.database.impl + +import app.shosetsu.android.common.ext.onIO +import app.shosetsu.android.datasource.local.database.base.IDBNovelCategoriesDataSource +import app.shosetsu.android.domain.model.database.DBNovelCategoryEntity +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import app.shosetsu.android.dto.convertList +import app.shosetsu.android.providers.database.dao.NovelCategoriesDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * Shosetsu + * 10 / May / 2020 + * + * @author github.com/doomsdayrs + */ +class DBNovelCategoriesDataSource( + private val novelCategoriesDao: NovelCategoriesDao, +) : IDBNovelCategoriesDataSource { + + override fun getNovelCategoriesFromNovelFlow(novelID: Int): Flow> = + novelCategoriesDao.getNovelCategoriesFromNovelFlow(novelID).map { it.convertList() } + + override suspend fun getNovelCategoriesFromNovel(novelID: Int): List = + onIO { novelCategoriesDao.getNovelCategoriesFromNovel(novelID).convertList() } + + override fun getNovelCategoriesFromCategoryFlow(categoryID: Int): Flow> = + novelCategoriesDao.getNovelCategoriesFromCategoryFlow(categoryID).map { it.convertList() } + + override suspend fun setNovelCategories(entities: List) = + novelCategoriesDao.insertAllIgnore(entities.toDB()) + + override suspend fun deleteNovelCategories(novelID: Int) = + onIO { novelCategoriesDao.deleteNovelCategories(novelID) } + + override suspend fun deleteNovelsCategories(novelIDs: List) = + onIO { novelCategoriesDao.deleteNovelsCategories(novelIDs) } + + fun NovelCategoryEntity.toDB() = + DBNovelCategoryEntity( + id = null, + novelID = novelID, + categoryID = categoryID + ) + + fun List.toDB() = map { it.toDB() } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/datasource/remote/impl/update/FDroidAppUpdateDataSource.kt b/android/src/main/java/app/shosetsu/android/datasource/remote/impl/update/FDroidAppUpdateDataSource.kt index f9733fd1b1..8ffa5b8da0 100644 --- a/android/src/main/java/app/shosetsu/android/datasource/remote/impl/update/FDroidAppUpdateDataSource.kt +++ b/android/src/main/java/app/shosetsu/android/datasource/remote/impl/update/FDroidAppUpdateDataSource.kt @@ -1,18 +1,89 @@ package app.shosetsu.android.datasource.remote.impl.update +import app.shosetsu.android.common.EmptyResponseBodyException +import app.shosetsu.android.common.ext.quickie import app.shosetsu.android.datasource.remote.base.IRemoteAppUpdateDataSource import app.shosetsu.android.domain.model.local.AppUpdateEntity +import app.shosetsu.lib.exceptions.HTTPException +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.OkHttpClient +import java.io.IOException import java.io.InputStream -class FDroidAppUpdateDataSource : IRemoteAppUpdateDataSource, +/** + * Load app updates from F-Droid + */ +class FDroidAppUpdateDataSource( + private val okHttpClient: OkHttpClient +) : IRemoteAppUpdateDataSource, IRemoteAppUpdateDataSource.Downloadable { + companion object { + private const val FDROID_UPDATE_URL = + "https://f-droid.org/api/v1/packages/app.shosetsu.android" + private const val FDROID_DOWNLOAD_URL = "https://f-droid.org/repo/app.shosetsu.android_" + + private val json = Json { + encodeDefaults = true + } + } + + @Serializable + data class PackagesInfo( + val packageName: String = "", + val suggestedVersionCode: Int = -1, + val packages: List = emptyList(), + val error: String? = null + ) + + @Serializable + data class PackageData( + val versionName: String, + val versionCode: Int + ) + + @Throws(HTTPException::class, EmptyResponseBodyException::class, IOException::class) + @OptIn(ExperimentalSerializationApi::class) override suspend fun loadAppUpdate(): AppUpdateEntity { - TODO("Add F-DROID update source") + okHttpClient.quickie(FDROID_UPDATE_URL).use { response -> + if (response.isSuccessful) { + return response.body?.use { responseBody -> + responseBody.byteStream().use { responseStream -> + val info = json.decodeFromStream(responseStream) + if (info.error != null) + throw EmptyResponseBodyException(info.error) + + var packageData = info.packages.firstOrNull { + it.versionCode == info.suggestedVersionCode + } + + if (packageData == null) + packageData = info.packages.first() + + AppUpdateEntity( + packageData.versionName, + packageData.versionCode, + url = FDROID_DOWNLOAD_URL + "${packageData.versionCode}.apk", + notes = emptyList() + ) + } + } ?: throw EmptyResponseBodyException(FDROID_UPDATE_URL) + } + throw HTTPException(response.code) + } } + @Throws(EmptyResponseBodyException::class, HTTPException::class, IOException::class) override suspend fun downloadAppUpdate(update: AppUpdateEntity): InputStream { - TODO("Add F-DROID update source") + okHttpClient.quickie(update.url).let { response -> + if (response.isSuccessful) { + return response.body?.byteStream() + ?: throw EmptyResponseBodyException(update.url) + } else throw HTTPException(response.code) + } } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/di/DatabaseModule.kt b/android/src/main/java/app/shosetsu/android/di/DatabaseModule.kt index 6771551f6f..cfd8f46b69 100644 --- a/android/src/main/java/app/shosetsu/android/di/DatabaseModule.kt +++ b/android/src/main/java/app/shosetsu/android/di/DatabaseModule.kt @@ -35,11 +35,13 @@ import org.kodein.di.singleton val databaseModule: DI.Module = DI.Module("database_module") { bind() with singleton { getRoomDatabase(instance()) } + bind() with singleton { instance().categoriesDao } bind() with singleton { instance().chaptersDao } bind() with singleton { instance().downloadsDao } bind() with singleton { instance().extensionLibraryDao } bind() with singleton { instance().installedExtensionsDao } bind() with singleton { instance().repositoryExtensionDao } + bind() with singleton { instance().novelCategoriesDao } bind() with singleton { instance().novelReaderSettingsDao } bind() with singleton { instance().novelsDao } bind() with singleton { instance().novelSettingsDao } diff --git a/android/src/main/java/app/shosetsu/android/di/RepositoryModule.kt b/android/src/main/java/app/shosetsu/android/di/RepositoryModule.kt index b65892ce12..0536bf41bb 100644 --- a/android/src/main/java/app/shosetsu/android/di/RepositoryModule.kt +++ b/android/src/main/java/app/shosetsu/android/di/RepositoryModule.kt @@ -32,6 +32,10 @@ import org.kodein.di.singleton */ val repositoryModule: DI.Module = DI.Module("repository_module") { + bind() with singleton { + CategoryRepository(instance()) + } + bind() with singleton { ChaptersRepository(instance(), instance(), instance(), instance(), instance()) } @@ -48,6 +52,10 @@ val repositoryModule: DI.Module = DI.Module("repository_module") { bind() with singleton { ExtRepoRepository(instance(), instance()) } + bind() with singleton { + NovelCategoryRepository(instance()) + } + bind() with singleton { NovelsRepository( instance(), diff --git a/android/src/main/java/app/shosetsu/android/di/UseCaseModule.kt b/android/src/main/java/app/shosetsu/android/di/UseCaseModule.kt index 80e5c8072c..feca03e1a0 100644 --- a/android/src/main/java/app/shosetsu/android/di/UseCaseModule.kt +++ b/android/src/main/java/app/shosetsu/android/di/UseCaseModule.kt @@ -43,7 +43,7 @@ val useCaseModule: DI.Module = DI.Module("useCase") { bind() with provider { LoadDownloadsUseCase(instance()) } - bind() with provider { LoadLibraryUseCase(instance()) } + bind() with provider { LoadLibraryUseCase(instance(), instance()) } bind() with provider { SearchBookMarkedNovelsUseCase(instance()) } @@ -83,7 +83,7 @@ val useCaseModule: DI.Module = DI.Module("useCase") { GetRemoteNovelUseCase(instance(), instance(), instance(), instance()) } bind() with provider { - StartDownloadWorkerAfterUpdateUseCase(instance(), instance(), instance()) + StartDownloadWorkerAfterUpdateUseCase(instance(), instance(), instance(), instance()) } bind() with provider { @@ -231,6 +231,31 @@ val useCaseModule: DI.Module = DI.Module("useCase") { LoadLibraryFilterSettingsUseCase(instance()) } + bind() with provider { GetCategoriesUseCase(instance()) } + + bind() with provider { + AddCategoryUseCase(instance()) + } + + bind() with provider { + DeleteCategoryUseCase(instance()) + } + + bind() with provider { + MoveCategoryUseCase(instance(), instance()) + } + + bind() with provider { + GetNovelCategoriesUseCase(instance()) + } + + bind() with provider { + SetNovelCategoriesUseCase(instance()) + } + + bind() with provider { + SetNovelsCategoriesUseCase(instance()) + } bind() with provider { UpdateLibraryFilterSettingsUseCase(instance()) diff --git a/android/src/main/java/app/shosetsu/android/di/ViewModelsModule.kt b/android/src/main/java/app/shosetsu/android/di/ViewModelsModule.kt index 25a022d540..b4eea49508 100644 --- a/android/src/main/java/app/shosetsu/android/di/ViewModelsModule.kt +++ b/android/src/main/java/app/shosetsu/android/di/ViewModelsModule.kt @@ -59,6 +59,7 @@ val viewModelsModule: DI.Module = DI.Module("view_models_module") { startUpdateWorkerUseCase = instance(), loadNovelUITypeUseCase = instance(), setNovelUITypeUseCase = instance(), + setNovelsCategoriesUseCase = instance(), loadNovelUIColumnsH = instance(), loadNovelUIColumnsP = instance(), loadNovelUIBadgeToast = instance() @@ -125,6 +126,18 @@ val viewModelsModule: DI.Module = DI.Module("view_models_module") { loadNovelUITypeUseCase = instance(), loadNovelUIColumnsHUseCase = instance(), loadNovelUIColumnsPUseCase = instance(), + setNovelUIType = instance(), + getCategoriesUseCase = instance(), + setNovelCategoriesUseCase = instance() + ) + } + + // Catalog(s) + bind() with provider { + CategoriesViewModel( + instance(), + instance(), + instance(), instance() ) } @@ -175,7 +188,10 @@ val viewModelsModule: DI.Module = DI.Module("view_models_module") { trueDeleteChapter = instance(), getInstalledExtensionUseCase = instance(), getRepositoryUseCase = instance(), - chapterRepo = instance() + chapterRepo = instance(), + getCategoriesUseCase = instance(), + getNovelCategoriesUseCase = instance(), + setNovelCategoriesUseCase = instance() ) } @@ -251,6 +267,7 @@ val viewModelsModule: DI.Module = DI.Module("view_models_module") { iSettingsRepository = instance(), instance(), instance(), + instance(), instance() ) } diff --git a/android/src/main/java/app/shosetsu/android/domain/model/database/DBCategoryEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/database/DBCategoryEntity.kt new file mode 100644 index 0000000000..787525e4a7 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/model/database/DBCategoryEntity.kt @@ -0,0 +1,51 @@ +package app.shosetsu.android.domain.model.database + +import androidx.annotation.NonNull +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.dto.Convertible + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * shosetsu + * 08 / 08 / 2022 + */ +@Entity( + tableName = "categories", +) +data class DBCategoryEntity( + @PrimaryKey(autoGenerate = true) + /** ID of this category */ + val id: Int? = null, + + /** Name of this category */ + @NonNull + val name: String, + + /** order of this category */ + @NonNull + val order: Int, +) : Convertible { + override fun convertTo(): CategoryEntity = CategoryEntity( + id, + name, + order + ) +} diff --git a/android/src/main/java/app/shosetsu/android/domain/model/database/DBNovelCategoryEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/database/DBNovelCategoryEntity.kt new file mode 100644 index 0000000000..ec003f51b2 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/model/database/DBNovelCategoryEntity.kt @@ -0,0 +1,63 @@ +package app.shosetsu.android.domain.model.database + +import androidx.annotation.NonNull +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import app.shosetsu.android.dto.Convertible + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * shosetsu + * 08 / 08 / 2022 + */ +@Entity( + tableName = "novel_categories", + foreignKeys = [ + ForeignKey( + entity = DBNovelEntity::class, + parentColumns = ["id"], + childColumns = ["novelID"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = DBCategoryEntity::class, + parentColumns = ["id"], + childColumns = ["categoryID"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("categoryID"), Index("novelID")] +) +data class DBNovelCategoryEntity( + /** Extension ID */ + @PrimaryKey(autoGenerate = true) + val id: Int? = null, + @NonNull + val novelID: Int, + @NonNull + val categoryID: Int, +) : Convertible { + override fun convertTo(): NovelCategoryEntity = NovelCategoryEntity( + novelID, + categoryID + ) +} diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/CategoryEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/CategoryEntity.kt new file mode 100644 index 0000000000..6b2703b635 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/CategoryEntity.kt @@ -0,0 +1,31 @@ +package app.shosetsu.android.domain.model.local + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + * ==================================================================== + */ + +/** + * shosetsu + * 08 / 08 / 2022 + */ +data class CategoryEntity( + var id: Int? = null, + + var name: String, + + var order: Int +) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/LibraryNovelEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/LibraryNovelEntity.kt index 096922e863..5195b0a15e 100644 --- a/android/src/main/java/app/shosetsu/android/domain/model/local/LibraryNovelEntity.kt +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/LibraryNovelEntity.kt @@ -1,5 +1,7 @@ package app.shosetsu.android.domain.model.local +import app.shosetsu.lib.Novel + /* * This file is part of shosetsu. * @@ -39,4 +41,6 @@ data class LibraryNovelEntity( val authors: List, val artists: List, val tags: List, + val status: Novel.Status, + val category: Int, ) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/NovelCategoryEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/NovelCategoryEntity.kt new file mode 100644 index 0000000000..8befe08f5d --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/NovelCategoryEntity.kt @@ -0,0 +1,29 @@ +package app.shosetsu.android.domain.model.local + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + * ==================================================================== + */ + +/** + * shosetsu + * 08 / 08 / 2022 + */ + +data class NovelCategoryEntity( + val novelID: Int, + val categoryID: Int, +) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupCategoryEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupCategoryEntity.kt new file mode 100644 index 0000000000..02c161820f --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupCategoryEntity.kt @@ -0,0 +1,27 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.domain.model.local.backup + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupCategoryEntity( + val name: String, + val order: Int, +) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupNovelEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupNovelEntity.kt index eb1171e25d..9b49d5b05a 100644 --- a/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupNovelEntity.kt +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/BackupNovelEntity.kt @@ -8,5 +8,6 @@ data class BackupNovelEntity( val name: String, val imageURL: String = "", val chapters: List = emptyList(), - val settings: BackupNovelSettingEntity = BackupNovelSettingEntity() + val settings: BackupNovelSettingEntity = BackupNovelSettingEntity(), + val categories: List = emptyList() ) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/model/local/backup/FleshedBackupEntity.kt b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/FleshedBackupEntity.kt index 085d4def31..f712a82927 100644 --- a/android/src/main/java/app/shosetsu/android/domain/model/local/backup/FleshedBackupEntity.kt +++ b/android/src/main/java/app/shosetsu/android/domain/model/local/backup/FleshedBackupEntity.kt @@ -12,6 +12,7 @@ data class FleshedBackupEntity( val version: String = VERSION_BACKUP, val repos: List = emptyList(), val extensions: List = emptyList(), + val categories: List = emptyList() ) @Serializable diff --git a/android/src/main/java/app/shosetsu/android/domain/repository/base/ICategoryRepository.kt b/android/src/main/java/app/shosetsu/android/domain/repository/base/ICategoryRepository.kt new file mode 100644 index 0000000000..511dc55ca7 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/repository/base/ICategoryRepository.kt @@ -0,0 +1,67 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.domain.repository.base + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.CategoryEntity +import kotlinx.coroutines.flow.Flow + +interface ICategoryRepository { + + /** + * Loads all [CategoryEntity]s in a flow + */ + fun getCategoriesAsFlow(): Flow> + + /** + * Loads all [CategoryEntity]s in a flow + */ + @Throws(SQLiteException::class) + suspend fun getCategories(): List + + /** + * Add category to the database + */ + @Throws(SQLiteException::class) + suspend fun addCategory(categoryEntity: CategoryEntity): Long + + /** + * If the category already exists + */ + @Throws(SQLiteException::class) + suspend fun categoryExists(name: String): Boolean + + /** + * Get the next [CategoryEntity.order] variable + */ + @Throws(SQLiteException::class) + suspend fun getNextCategoryOrder(): Int + + /** + * Delete a [CategoryEntity] from the database + */ + @Throws(SQLiteException::class) + suspend fun deleteCategory(categoryEntity: CategoryEntity) + + /** + * Update a list of [CategoryEntity]s + */ + @Throws(SQLiteException::class) + suspend fun updateCategories(categories: List) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/repository/base/INovelCategoryRepository.kt b/android/src/main/java/app/shosetsu/android/domain/repository/base/INovelCategoryRepository.kt new file mode 100644 index 0000000000..6b038de8bd --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/repository/base/INovelCategoryRepository.kt @@ -0,0 +1,60 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.domain.repository.base + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import kotlinx.coroutines.flow.Flow + +interface INovelCategoryRepository { + + /** + * Loads all [NovelCategoryEntity]s from a novel id in a flow + */ + fun getNovelCategoriesFromNovelFlow(novelID: Int): Flow> + + /** + * Loads all [NovelCategoryEntity]s from a novel id + */ + @Throws(SQLiteException::class) + suspend fun getNovelCategoriesFromNovel(novelID: Int): List + + /** + * Loads all [NovelCategoryEntity]s from a category id in a flow + */ + fun getNovelCategoriesFromCategoryFlow(categoryID: Int): Flow> + + /** + * Set the categories for a novel + */ + @Throws(SQLiteException::class) + suspend fun setNovelCategories(entities: List): Array + + /** + * Delete the categories for a novel + */ + @Throws(SQLiteException::class) + suspend fun deleteNovelCategories(novelID: Int) + + /** + * Delete the categories for multiple novels + */ + @Throws(SQLiteException::class) + suspend fun deleteNovelsCategories(novelIDs: List) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/repository/impl/CategoryRepository.kt b/android/src/main/java/app/shosetsu/android/domain/repository/impl/CategoryRepository.kt new file mode 100644 index 0000000000..fd56ea9caf --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/repository/impl/CategoryRepository.kt @@ -0,0 +1,57 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.domain.repository.impl + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.datasource.local.database.base.IDBCategoriesDataSource +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.domain.repository.base.ICategoryRepository +import kotlinx.coroutines.flow.Flow + +class CategoryRepository( + private val database: IDBCategoriesDataSource, +) : ICategoryRepository { + + override fun getCategoriesAsFlow(): Flow> = + database.getCategoriesFlow() + + @Throws(SQLiteException::class) + override suspend fun getCategories(): List = + database.getCategories() + + @Throws(SQLiteException::class) + override suspend fun addCategory(categoryEntity: CategoryEntity) = + database.addCategory(categoryEntity) + + @Throws(SQLiteException::class) + override suspend fun categoryExists(name: String): Boolean = + database.categoryExists(name) + + @Throws(SQLiteException::class) + override suspend fun getNextCategoryOrder() = + database.getNextCategoryOrder() + + @Throws(SQLiteException::class) + override suspend fun deleteCategory(categoryEntity: CategoryEntity) = + database.deleteCategory(categoryEntity) + + @Throws(SQLiteException::class) + override suspend fun updateCategories(categories: List) = + database.updateCategories(categories) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/repository/impl/NovelCategoryRepository.kt b/android/src/main/java/app/shosetsu/android/domain/repository/impl/NovelCategoryRepository.kt new file mode 100644 index 0000000000..64da69078d --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/repository/impl/NovelCategoryRepository.kt @@ -0,0 +1,47 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.domain.repository.impl + +import app.shosetsu.android.datasource.local.database.base.IDBNovelCategoriesDataSource +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import app.shosetsu.android.domain.repository.base.INovelCategoryRepository +import kotlinx.coroutines.flow.Flow + +class NovelCategoryRepository( + private val database: IDBNovelCategoriesDataSource, +) : INovelCategoryRepository { + + override fun getNovelCategoriesFromNovelFlow(novelID: Int): Flow> = + database.getNovelCategoriesFromNovelFlow(novelID) + + override suspend fun getNovelCategoriesFromNovel(novelID: Int): List = + database.getNovelCategoriesFromNovel(novelID) + + override fun getNovelCategoriesFromCategoryFlow(categoryID: Int): Flow> = + database.getNovelCategoriesFromCategoryFlow(categoryID) + + override suspend fun setNovelCategories(entities: List) = + database.setNovelCategories(entities) + + override suspend fun deleteNovelCategories(novelID: Int) = + database.deleteNovelCategories(novelID) + + override suspend fun deleteNovelsCategories(novelIDs: List) = + database.deleteNovelsCategories(novelIDs) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/AddCategoryUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/AddCategoryUseCase.kt new file mode 100644 index 0000000000..991abd19c9 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/AddCategoryUseCase.kt @@ -0,0 +1,40 @@ +package app.shosetsu.android.domain.usecases + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.domain.repository.base.ICategoryRepository + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * 13 / 01 / 2021 + */ +class AddCategoryUseCase( + private val repo: ICategoryRepository +) { + @Throws(SQLiteException::class) + suspend operator fun invoke(name: String): Int { + if (repo.categoryExists(name)) { + throw Exception("Name already exists") + } + + val nextOrder = repo.getNextCategoryOrder() + + return repo.addCategory(CategoryEntity(name = name, order = nextOrder)).toInt() + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/DeleteCategoryUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/DeleteCategoryUseCase.kt new file mode 100644 index 0000000000..53f88d784d --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/DeleteCategoryUseCase.kt @@ -0,0 +1,32 @@ +package app.shosetsu.android.domain.usecases + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.repository.base.ICategoryRepository +import app.shosetsu.android.view.uimodels.model.CategoryUI + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * 13 / 01 / 2021 + */ +class DeleteCategoryUseCase( + private val repo: ICategoryRepository +) { + @Throws(SQLiteException::class) + suspend operator fun invoke(category: CategoryUI) = repo.deleteCategory(category.convertTo()) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/MoveCategoryUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/MoveCategoryUseCase.kt new file mode 100644 index 0000000000..884c23405e --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/MoveCategoryUseCase.kt @@ -0,0 +1,53 @@ +package app.shosetsu.android.domain.usecases + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.repository.base.ICategoryRepository +import app.shosetsu.android.domain.usecases.get.GetCategoriesUseCase +import app.shosetsu.android.view.uimodels.model.CategoryUI +import kotlinx.coroutines.flow.first + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * 13 / 01 / 2021 + */ +class MoveCategoryUseCase( + private val repo: ICategoryRepository, + private val getCategoriesUseCase: GetCategoriesUseCase, +) { + @Throws(SQLiteException::class) + suspend operator fun invoke(categoryUI: CategoryUI, newOrder: Int) { + val unalteredNewOrder = newOrder - 1 + val categories = getCategoriesUseCase().first() + + val currentIndex = categories.indexOfFirst { it.id == categoryUI.id } + if (currentIndex == unalteredNewOrder) return + + val reorderedCategories = categories.toMutableList() + val reorderedCategory = reorderedCategories.removeAt(currentIndex) + reorderedCategories.add(unalteredNewOrder, reorderedCategory) + + val updatedCategories = reorderedCategories.mapIndexed { index, categoryEntity -> + categoryEntity.convertTo().copy( + order = index + 1 + ) + } + + repo.updateCategories(updatedCategories) + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelCategoriesUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelCategoriesUseCase.kt new file mode 100644 index 0000000000..1c9f89228f --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelCategoriesUseCase.kt @@ -0,0 +1,41 @@ +package app.shosetsu.android.domain.usecases + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import app.shosetsu.android.domain.repository.base.INovelCategoryRepository + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * 13 / 01 / 2021 + */ +class SetNovelCategoriesUseCase( + private val repo: INovelCategoryRepository +) { + @Throws(SQLiteException::class) + suspend operator fun invoke(novelID: Int, categories: IntArray) { + val entities = categories.filterNot { it == 0 }.map { + NovelCategoryEntity(novelID, it) + } + + repo.deleteNovelCategories(novelID) + if (entities.isNotEmpty()) { + repo.setNovelCategories(entities) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelsCategoriesUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelsCategoriesUseCase.kt new file mode 100644 index 0000000000..cb73b889cf --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/SetNovelsCategoriesUseCase.kt @@ -0,0 +1,46 @@ +package app.shosetsu.android.domain.usecases + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.model.local.NovelCategoryEntity +import app.shosetsu.android.domain.repository.base.INovelCategoryRepository + +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + */ + +/** + * 13 / 01 / 2021 + */ +class SetNovelsCategoriesUseCase( + private val repo: INovelCategoryRepository +) { + @Throws(SQLiteException::class) + suspend operator fun invoke(novelIDs: IntArray, categories: IntArray) { + val entities = categories.filterNot { it == 0 }.distinct().flatMap { categoryID -> + novelIDs.distinct().map { novelID -> + NovelCategoryEntity( + novelID = novelID, + categoryID = categoryID + ) + } + } + + repo.deleteNovelsCategories(novelIDs.toList()) + if (entities.isNotEmpty()) { + repo.setNovelCategories(entities) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/StartDownloadWorkerAfterUpdateUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/StartDownloadWorkerAfterUpdateUseCase.kt index 3b827bf0da..3ee59b58a7 100644 --- a/android/src/main/java/app/shosetsu/android/domain/usecases/StartDownloadWorkerAfterUpdateUseCase.kt +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/StartDownloadWorkerAfterUpdateUseCase.kt @@ -1,9 +1,13 @@ package app.shosetsu.android.domain.usecases import app.shosetsu.android.common.SettingKey +import app.shosetsu.android.common.SettingKey.ExcludedCategoriesToDownload +import app.shosetsu.android.common.SettingKey.IncludeCategoriesToDownload import app.shosetsu.android.domain.model.local.ChapterEntity import app.shosetsu.android.domain.repository.base.ISettingsRepository +import app.shosetsu.android.domain.usecases.get.GetNovelCategoriesUseCase import app.shosetsu.android.domain.usecases.start.StartDownloadWorkerUseCase +import kotlinx.coroutines.flow.first /* * This file is part of Shosetsu. @@ -33,7 +37,8 @@ import app.shosetsu.android.domain.usecases.start.StartDownloadWorkerUseCase class StartDownloadWorkerAfterUpdateUseCase( private val sR: ISettingsRepository, private val download: DownloadChapterPassageUseCase, - private val startDownloadWorker: StartDownloadWorkerUseCase + private val startDownloadWorker: StartDownloadWorkerUseCase, + private val getNovelCategoriesUseCase: GetNovelCategoriesUseCase ) { /** @@ -42,7 +47,21 @@ class StartDownloadWorkerAfterUpdateUseCase( suspend operator fun invoke(chapters: List): Boolean = sR.getBoolean(SettingKey.DownloadNewNovelChapters).let { isDownloadOnUpdate -> if (isDownloadOnUpdate) { - download(chapters) + val includedToDownload = sR.getStringSet(IncludeCategoriesToDownload) + .map(String::toInt) + val excludedToDownload = sR.getStringSet(ExcludedCategoriesToDownload) + .map(String::toInt) + val filteredChapters = chapters + .groupBy { it.novelID } + .filter { (novelID) -> + val categories = getNovelCategoriesUseCase(novelID).first().ifEmpty { listOf(0) } + categories.any { includedToDownload.isEmpty() || it in includedToDownload } && + categories.none { it in excludedToDownload } + } + .values + .flatten() + + download(filteredChapters) startDownloadWorker() true } else diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetCategoriesUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetCategoriesUseCase.kt new file mode 100644 index 0000000000..2c2667a2f2 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetCategoriesUseCase.kt @@ -0,0 +1,39 @@ +package app.shosetsu.android.domain.usecases.get + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.repository.base.ICategoryRepository +import app.shosetsu.android.view.uimodels.model.CategoryUI +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.mapLatest + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + +/** + * Shosetsu + * + * @since 06 / 03 / 2022 + * @author Doomsdayrs + */ +class GetCategoriesUseCase( + private val repo: ICategoryRepository +) { + @OptIn(ExperimentalCoroutinesApi::class) + @Throws(SQLiteException::class) + operator fun invoke() = repo.getCategoriesAsFlow() + .mapLatest { entities -> entities.map { CategoryUI(it.id!!, it.name, it.order) }.sortedBy { it.order } } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetNovelCategoriesUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetNovelCategoriesUseCase.kt new file mode 100644 index 0000000000..cc4fc5ecf7 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/get/GetNovelCategoriesUseCase.kt @@ -0,0 +1,38 @@ +package app.shosetsu.android.domain.usecases.get + +import android.database.sqlite.SQLiteException +import app.shosetsu.android.domain.repository.base.INovelCategoryRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.mapLatest + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + +/** + * Shosetsu + * + * @since 06 / 03 / 2022 + * @author Doomsdayrs + */ +class GetNovelCategoriesUseCase( + private val repo: INovelCategoryRepository +) { + @OptIn(ExperimentalCoroutinesApi::class) + @Throws(SQLiteException::class) + operator fun invoke(novelID: Int) = repo.getNovelCategoriesFromNovelFlow(novelID) + .mapLatest { entities -> entities.map { it.categoryID } } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/load/LoadLibraryUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/load/LoadLibraryUseCase.kt index e449e8f026..faa40c29a2 100644 --- a/android/src/main/java/app/shosetsu/android/domain/usecases/load/LoadLibraryUseCase.kt +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/load/LoadLibraryUseCase.kt @@ -2,9 +2,12 @@ package app.shosetsu.android.domain.usecases.load import android.database.sqlite.SQLiteException import app.shosetsu.android.domain.repository.base.INovelsRepository +import app.shosetsu.android.domain.usecases.get.GetCategoriesUseCase import app.shosetsu.android.view.uimodels.model.LibraryNovelUI +import app.shosetsu.android.view.uimodels.model.LibraryUI import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapLatest /* @@ -30,10 +33,11 @@ import kotlinx.coroutines.flow.mapLatest */ class LoadLibraryUseCase( private val novelsRepo: INovelsRepository, + private val getCategoriesUseCase: GetCategoriesUseCase ) { @Throws(SQLiteException::class) @OptIn(ExperimentalCoroutinesApi::class) - operator fun invoke(): Flow> = + operator fun invoke(): Flow = novelsRepo.loadLibraryNovelEntities().mapLatest { origin -> origin.map { (id, title, @@ -43,10 +47,14 @@ class LoadLibraryUseCase( genres, authors, artists, - tags) -> + tags, + status, + category) -> LibraryNovelUI( - id, title, imageURL, bookmarked, unread, genres, authors, artists, tags + id, title, imageURL, bookmarked, unread, genres, authors, artists, tags, status, category ) - } + }.groupBy { it.category } + }.combine(getCategoriesUseCase()) { novels, categories -> + LibraryUI(categories, novels) } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/domain/usecases/start/StartUpdateWorkerUseCase.kt b/android/src/main/java/app/shosetsu/android/domain/usecases/start/StartUpdateWorkerUseCase.kt index c9926c5bc3..b83b75be18 100644 --- a/android/src/main/java/app/shosetsu/android/domain/usecases/start/StartUpdateWorkerUseCase.kt +++ b/android/src/main/java/app/shosetsu/android/domain/usecases/start/StartUpdateWorkerUseCase.kt @@ -1,6 +1,8 @@ package app.shosetsu.android.domain.usecases.start +import androidx.work.Data import androidx.work.await +import app.shosetsu.android.backend.workers.onetime.NovelUpdateWorker import app.shosetsu.android.backend.workers.onetime.NovelUpdateWorker.Manager import app.shosetsu.android.common.ext.launchIO @@ -32,7 +34,7 @@ class StartUpdateWorkerUseCase( * Starts the update worker * @param override if true then will override the current update loop */ - operator fun invoke(override: Boolean = false) { + operator fun invoke(categoryID: Int, override: Boolean = false) { launchIO { if (manager.isRunning()) if (override) @@ -40,7 +42,11 @@ class StartUpdateWorkerUseCase( else return@launchIO - manager.start() + if (categoryID >= 0) { + manager.start(Data(mapOf(NovelUpdateWorker.KEY_CATEGORY to categoryID))) + } else { + manager.start() + } } } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/providers/database/ShosetsuDatabase.kt b/android/src/main/java/app/shosetsu/android/providers/database/ShosetsuDatabase.kt index aadee3458c..89b92343ce 100644 --- a/android/src/main/java/app/shosetsu/android/providers/database/ShosetsuDatabase.kt +++ b/android/src/main/java/app/shosetsu/android/providers/database/ShosetsuDatabase.kt @@ -38,18 +38,20 @@ import kotlinx.coroutines.launch @Fts4 @Database( entities = [ + DBCategoryEntity::class, DBChapterEntity::class, DBDownloadEntity::class, DBInstalledExtensionEntity::class, DBRepositoryExtensionEntity::class, DBExtLibEntity::class, + DBNovelCategoryEntity::class, DBNovelReaderSettingEntity::class, DBNovelEntity::class, DBNovelSettingsEntity::class, DBRepositoryEntity::class, DBUpdate::class, ], - version = 6 + version = 7 ) @TypeConverters( ChapterSortTypeConverter::class, @@ -65,11 +67,13 @@ import kotlinx.coroutines.launch @GenerateRoomMigrations abstract class ShosetsuDatabase : RoomDatabase() { + abstract val categoriesDao: CategoriesDao abstract val chaptersDao: ChaptersDao abstract val downloadsDao: DownloadsDao abstract val extensionLibraryDao: ExtensionLibraryDao abstract val installedExtensionsDao: InstalledExtensionsDao abstract val repositoryExtensionDao: RepositoryExtensionsDao + abstract val novelCategoriesDao: NovelCategoriesDao abstract val novelReaderSettingsDao: NovelReaderSettingsDao abstract val novelsDao: NovelsDao abstract val novelSettingsDao: NovelSettingsDao @@ -93,6 +97,7 @@ abstract class ShosetsuDatabase : RoomDatabase() { Migration3To4, Migration4To5, Migration5To6, + Migration6To7 ).build() GlobalScope.launch { diff --git a/android/src/main/java/app/shosetsu/android/providers/database/dao/CategoriesDao.kt b/android/src/main/java/app/shosetsu/android/providers/database/dao/CategoriesDao.kt new file mode 100644 index 0000000000..64a06fca03 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/providers/database/dao/CategoriesDao.kt @@ -0,0 +1,71 @@ +package app.shosetsu.android.providers.database.dao + +import android.database.sqlite.SQLiteException +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import app.shosetsu.android.domain.model.database.DBCategoryEntity +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.providers.database.dao.base.BaseDao +import kotlinx.coroutines.flow.Flow + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + +/** + * shosetsu + * 08 / 08 / 2022 + * + * @author github.com/doomsdayrs + */ +@Dao +interface CategoriesDao : BaseDao { + + //# Queries + + /** + * Gets a flow of the categories + */ + @Query("SELECT * FROM categories") + fun getCategoriesFlow(): Flow> + + /** + * Gets a list of the categories + */ + @Query("SELECT * FROM categories") + suspend fun getCategories(): List + + /** + * If the category already exists + */ + @Query("SELECT count(*) FROM categories WHERE name LIKE :name") + suspend fun categoryExists(name: String): Int + + /** + * Get the next [CategoryEntity.order] variable + */ + @Query("SELECT count(*) + 1 FROM categories") + suspend fun getNextCategoryOrder(): Int + + @Transaction + @Throws(SQLiteException::class) + suspend fun update(list: List) { + list.forEach { entity -> + update(entity) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelCategoriesDao.kt b/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelCategoriesDao.kt new file mode 100644 index 0000000000..54ffe26252 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelCategoriesDao.kt @@ -0,0 +1,66 @@ +package app.shosetsu.android.providers.database.dao + +import androidx.room.Dao +import androidx.room.Query +import app.shosetsu.android.domain.model.database.DBNovelCategoryEntity +import app.shosetsu.android.providers.database.dao.base.BaseDao +import kotlinx.coroutines.flow.Flow + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + +/** + * shosetsu + * 08 / 08 / 2022 + * + * @author github.com/doomsdayrs + */ +@Dao +interface NovelCategoriesDao : BaseDao { + + //# Queries + + /** + * Gets a flow of the novel categories corresponding to the novel + */ + @Query("SELECT * FROM novel_categories WHERE novelID = :novelID") + fun getNovelCategoriesFromNovelFlow(novelID: Int): Flow> + + /** + * Gets a flow of the novel categories corresponding to the novel + */ + @Query("SELECT * FROM novel_categories WHERE novelID = :novelID") + suspend fun getNovelCategoriesFromNovel(novelID: Int): List + + /** + * Gets a flow of the novel categories corresponding to the category + */ + @Query("SELECT * FROM novel_categories WHERE categoryID = :categoryID") + fun getNovelCategoriesFromCategoryFlow(categoryID: Int): Flow> + + /** + * Delete the categories for a novel + */ + @Query("DELETE FROM novel_categories WHERE novelID = :novelID") + suspend fun deleteNovelCategories(novelID: Int) + + /** + * Delete the categories for a novel + */ + @Query("DELETE FROM novel_categories WHERE novelID IN (:novelIDs)") + suspend fun deleteNovelsCategories(novelIDs: List) +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelsDao.kt b/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelsDao.kt index 0d5993dd94..d0cb84613b 100644 --- a/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelsDao.kt +++ b/android/src/main/java/app/shosetsu/android/providers/database/dao/NovelsDao.kt @@ -55,21 +55,33 @@ interface NovelsDao : BaseDao { @Throws(SQLiteException::class) @Query( - """SELECT - novels.id, - novels.title, - novels.imageURL, - novels.bookmarked, - ( - SELECT - count(*) - FROM chapters WHERE novelID = novels.id AND readingStatus != 2 - ) as unread, - novels.genres, - novels.authors, - novels.artists, - novels.tags - FROM novels WHERE novels.bookmarked = 1""" + """ + SELECT M.*, COALESCE(MC.categoryID, 0) AS category + FROM ( + SELECT novels.id, + novels.title, + novels.imageURL, + novels.bookmarked, + ( + SELECT count(*) + FROM chapters + WHERE novelID = novels.id + AND readingStatus != 2 + ) as unread, + novels.genres, + novels.authors, + novels.artists, + novels.tags, + novels.status + FROM novels + WHERE novels.bookmarked = 1 + ) AS M + LEFT JOIN ( + SELECT * + FROM novel_categories + ) AS MC + ON M.id = MC.novelID + """ ) fun loadBookmarkedNovelsFlow(): Flow> diff --git a/android/src/main/java/app/shosetsu/android/providers/database/migrations/Migration6To7.kt b/android/src/main/java/app/shosetsu/android/providers/database/migrations/Migration6To7.kt new file mode 100644 index 0000000000..0b003e700b --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/providers/database/migrations/Migration6To7.kt @@ -0,0 +1,39 @@ +package app.shosetsu.android.providers.database.migrations + +import android.database.SQLException +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/* + * This file is part of shosetsu. + * + * shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shosetsu. If not, see . + */ + +/** + * Shosetsu + * + * @since 08 / 08 / 2022 + */ +object Migration6To7 : Migration(6, 7) { + + @Throws(SQLException::class) + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE `categories` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `order` INTEGER NOT NULL)") + database.execSQL("CREATE TABLE `novel_categories` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `novelID` INTEGER NOT NULL, `categoryID` INTEGER NOT NULL, FOREIGN KEY(`novelID`) REFERENCES `novels`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`categoryID`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX `index_novel_categories_categoryID` ON `novel_categories` (`categoryID`)") + database.execSQL("CREATE INDEX `index_novel_categories_novelID` ON `novel_categories` (`novelID`)") + } + +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/ui/catalogue/CatalogController.kt b/android/src/main/java/app/shosetsu/android/ui/catalogue/CatalogController.kt index 50813132ba..4c394aad65 100644 --- a/android/src/main/java/app/shosetsu/android/ui/catalogue/CatalogController.kt +++ b/android/src/main/java/app/shosetsu/android/ui/catalogue/CatalogController.kt @@ -14,6 +14,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -37,6 +40,7 @@ import app.shosetsu.android.common.enums.NovelCardType import app.shosetsu.android.common.enums.NovelCardType.* import app.shosetsu.android.common.ext.* import app.shosetsu.android.ui.catalogue.listeners.CatalogueSearchQuery +import app.shosetsu.android.ui.novel.CategoriesDialog import app.shosetsu.android.view.ComposeBottomSheetDialog import app.shosetsu.android.view.compose.* import app.shosetsu.android.view.controller.ShosetsuController @@ -105,6 +109,9 @@ class CatalogController : ShosetsuController(), ExtendedFABController, MenuProvi val exception by viewModel.exceptionFlow.collectAsState(null) val hasFilters by viewModel.hasFilters.collectAsState(false) + val categories by viewModel.categories.collectAsState(emptyList()) + var categoriesDialogItem by remember { mutableStateOf(null) } + if (exception != null) LaunchedEffect(Unit) { launchUI { @@ -162,11 +169,28 @@ class CatalogController : ShosetsuController(), ExtendedFABController, MenuProvi } }, onLongClick = { - itemLongClicked(it) + if (categories.isNotEmpty() && !it.bookmarked) { + categoriesDialogItem = it + } else { + itemLongClicked(it) + } }, hasFilters = hasFilters, fab ) + if (categoriesDialogItem != null) { + CategoriesDialog( + onDismissRequest = { categoriesDialogItem = null }, + categories = categories, + setCategories = { + itemLongClicked( + item = categoriesDialogItem ?: return@CategoriesDialog, + categories = it + ) + }, + novelCategories = emptyList() + ) + } } } } @@ -175,7 +199,7 @@ class CatalogController : ShosetsuController(), ExtendedFABController, MenuProvi /** * A [ACatalogNovelUI] was long clicked, invoking a background add */ - private fun itemLongClicked(item: ACatalogNovelUI): Boolean { + private fun itemLongClicked(item: ACatalogNovelUI, categories: IntArray = intArrayOf()): Boolean { logI("Adding novel to library in background: $item") if (item.bookmarked) { @@ -183,7 +207,7 @@ class CatalogController : ShosetsuController(), ExtendedFABController, MenuProvi return false } - viewModel.backgroundNovelAdd(item.id).observe( + viewModel.backgroundNovelAdd(item.id, categories).observe( catch = { makeSnackBar( getString( diff --git a/android/src/main/java/app/shosetsu/android/ui/categories/CategoriesController.kt b/android/src/main/java/app/shosetsu/android/ui/categories/CategoriesController.kt new file mode 100644 index 0000000000..728a642345 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/ui/categories/CategoriesController.kt @@ -0,0 +1,207 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.ui.categories + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.shosetsu.android.R +import app.shosetsu.android.common.ext.logE +import app.shosetsu.android.common.ext.makeSnackBar +import app.shosetsu.android.common.ext.viewModel +import app.shosetsu.android.databinding.CategoriesAddBinding +import app.shosetsu.android.view.compose.ShosetsuCompose +import app.shosetsu.android.view.controller.ShosetsuController +import app.shosetsu.android.view.controller.base.ExtendedFABController +import app.shosetsu.android.view.controller.base.syncFABWithCompose +import app.shosetsu.android.view.uimodels.model.CategoryUI +import app.shosetsu.android.viewmodel.abstracted.ACategoriesViewModel +import kotlinx.coroutines.flow.Flow + +class CategoriesController : ShosetsuController(), ExtendedFABController { + + val viewModel: ACategoriesViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedViewState: Bundle? + ): View { + setViewTitle() + return ComposeView(requireContext()).apply { + setContent { + ShosetsuCompose { + val items by viewModel.liveData.collectAsState(emptyList()) + + + CategoriesContent( + items = items, + onRemove = { + onRemove(it, context) + }, + onMoveUp = { + viewModel.moveUp(it).observeMoveCategory() + }, + onMoveDown = { + viewModel.moveDown(it).observeMoveCategory() + }, + fab = fab + ) + } + } + } + } + + private fun onRemove(item: CategoryUI, context: Context) { + AlertDialog.Builder(context) + .setTitle(R.string.alert_dialog_title_warn_categories_removal) + .setMessage(R.string.alert_dialog_message_warn_categories_removal) + .setPositiveButton(android.R.string.ok) { _, _ -> + removeCategory(item) + }.setNegativeButton(android.R.string.cancel) { _, _ -> + }.show() + } + + private fun removeCategory(item: CategoryUI) { + // Pass item to viewModel to remove, observe result + viewModel.remove(item).observe( + catch = { + logE("Failed to remove category $item", it) + makeSnackBar(R.string.toast_categories_remove_fail) + ?.setAction(R.string.generic_question_retry) { + removeCategory(item) + }?.show() + } + ) { + // Inform user of the category being removed + makeSnackBar( + R.string.controller_categories_snackbar_repo_removed, + )?.show() + } + } + + + private fun addCategory(name: String) { + viewModel.addCategory(name).observe( + catch = { + // Inform the user the category couldn't be added + makeSnackBar(R.string.toast_categories_add_fail)?.show() + } + ) { + // Inform the user that the category was added + makeSnackBar(R.string.toast_categories_added)?.show() + } + } + + private fun Flow.observeMoveCategory() { + observe( + catch = { + // Inform the user the category couldn't be added + makeSnackBar(R.string.toast_categories_move_fail)?.show() + } + ) {} + } + + private fun launchAddCategoryDialog(view: View) { + val addBinding = CategoriesAddBinding.inflate(LayoutInflater.from(view.context)) + + AlertDialog.Builder(view.context) + .setView(addBinding.root) + .setTitle(R.string.categories_add_title) + .setPositiveButton(android.R.string.ok) { _, _ -> + with(addBinding) { + // Pass data to view model, observe result + addCategory( + nameInput.text.toString(), + ) + } + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } + + private lateinit var fab: ExtendedFABController.EFabMaintainer + override fun manipulateFAB(fab: ExtendedFABController.EFabMaintainer) { + this.fab = fab + fab.setIconResource(R.drawable.add_circle_outline) + fab.setText(R.string.controller_categories_action_add) + + // When the FAB is clicked, open a alert dialog to input a new category + fab.setOnClickListener { launchAddCategoryDialog(it) } + } + +} + +@Composable +fun CategoriesContent( + items: List, + onRemove: (CategoryUI) -> Unit, + onMoveUp: (CategoryUI) -> Unit, + onMoveDown: (CategoryUI) -> Unit, + fab: ExtendedFABController.EFabMaintainer +) { + val state = rememberLazyListState() + syncFABWithCompose(state, fab) + + LazyColumn( + Modifier.fillMaxSize(), + state, + contentPadding = PaddingValues(bottom = 64.dp, top = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(items) { + Card { + Column(Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text(it.name, style = MaterialTheme.typography.h6) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { onMoveDown(it) }) { + Icon(painterResource(R.drawable.expand_less), contentDescription = null) + } + IconButton(onClick = { onMoveUp(it) }) { + Icon(painterResource(R.drawable.expand_more), contentDescription = null) + } + IconButton(onClick = { onRemove(it) }) { + Icon(painterResource(R.drawable.trash), contentDescription = null) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/ui/library/LibraryController.kt b/android/src/main/java/app/shosetsu/android/ui/library/LibraryController.kt index 5d39ab7d4f..2278f96918 100644 --- a/android/src/main/java/app/shosetsu/android/ui/library/LibraryController.kt +++ b/android/src/main/java/app/shosetsu/android/ui/library/LibraryController.kt @@ -13,12 +13,21 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Tab +import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,6 +49,7 @@ import app.shosetsu.android.common.enums.NovelCardType.* import app.shosetsu.android.common.ext.* import app.shosetsu.android.ui.library.listener.LibrarySearchQuery import app.shosetsu.android.ui.migration.MigrationController +import app.shosetsu.android.ui.novel.CategoriesDialog import app.shosetsu.android.view.ComposeBottomSheetDialog import app.shosetsu.android.view.compose.* import app.shosetsu.android.view.controller.ShosetsuController @@ -48,11 +58,17 @@ import app.shosetsu.android.view.controller.base.ExtendedFABController.EFabMaint import app.shosetsu.android.view.controller.base.HomeFragment import app.shosetsu.android.view.controller.base.syncFABWithCompose import app.shosetsu.android.view.uimodels.model.LibraryNovelUI +import app.shosetsu.android.view.uimodels.model.LibraryUI import app.shosetsu.android.viewmodel.abstracted.ALibraryViewModel +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.pagerTabIndicatorOffset +import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.SwipeRefreshState import com.google.android.material.bottomsheet.BottomSheetDialog import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /* @@ -89,6 +105,7 @@ class LibraryController /***/ val viewModel: ALibraryViewModel by viewModel() + var categoriesDialogOpen by mutableStateOf(false) override fun onCreateView( inflater: LayoutInflater, @@ -100,7 +117,7 @@ class LibraryController return ComposeView(requireContext()).apply { setContent { ShosetsuCompose { - val items by viewModel.liveData.collectAsState(emptyList()) + val items by viewModel.liveData.collectAsState(null) val isEmpty by viewModel.isEmptyFlow.collectAsState(false) val hasSelected by viewModel.hasSelectionFlow.collectAsState(false) val type by viewModel.novelCardTypeFlow.collectAsState(NORMAL) @@ -116,12 +133,13 @@ class LibraryController LibraryContent( items, isEmpty = isEmpty, + setActiveCategory = viewModel::setActiveCategory, type, columnsInV, columnsInH, hasSelected = hasSelected, onRefresh = { - onRefresh() + onRefresh(it) }, onOpen = { (id) -> try { @@ -137,9 +155,7 @@ class LibraryController // ignore dup } }, - toggleSelection = { item -> - viewModel.toggleSelection(item) - }, + toggleSelection = viewModel::toggleSelection, toastNovel = if (badgeToast) { { item -> try { @@ -156,6 +172,14 @@ class LibraryController } else null, fab ) + if (categoriesDialogOpen) { + CategoriesDialog( + onDismissRequest = { categoriesDialogOpen = false }, + categories = items?.categories.orEmpty(), + novelCategories = emptyList(), + setCategories = viewModel::setCategories + ) + } } } } @@ -237,7 +261,7 @@ class LibraryController when (item.itemId) { R.id.updater_now -> { if (viewModel.isOnline()) - viewModel.startUpdateManager() + viewModel.startUpdateManager(-1) else displayOfflineSnackBar() true } @@ -274,6 +298,10 @@ class LibraryController true } + R.id.set_categories -> { + categoriesDialogOpen = true + true + } R.id.view_type_normal -> { item.isChecked = !item.isChecked viewModel.setViewType(NORMAL) @@ -326,154 +354,261 @@ class LibraryController fab.setIconResource(R.drawable.filter) } - fun onRefresh() { + fun onRefresh(categoryID: Int) { if (viewModel.isOnline()) - viewModel.startUpdateManager() + viewModel.startUpdateManager(categoryID) else displayOfflineSnackBar(R.string.generic_error_cannot_update_library_offline) } } -@OptIn(ExperimentalMaterialApi::class) @Composable fun LibraryContent( - items: List, + items: LibraryUI?, isEmpty: Boolean, + setActiveCategory: (Int) -> Unit, cardType: NovelCardType, columnsInV: Int, columnsInH: Int, hasSelected: Boolean, - onRefresh: () -> Unit, + onRefresh: (Int) -> Unit, onOpen: (LibraryNovelUI) -> Unit, toggleSelection: (LibraryNovelUI) -> Unit, toastNovel: ((LibraryNovelUI) -> Unit)?, fab: EFabMaintainer? ) { if (!isEmpty) { - SwipeRefresh( - state = SwipeRefreshState(false), - onRefresh = onRefresh - ) { - val w = LocalConfiguration.current.screenWidthDp - val o = LocalConfiguration.current.orientation - - val size = - (w / when (o) { - Configuration.ORIENTATION_LANDSCAPE -> columnsInH - else -> columnsInV - }).dp - 16.dp - - - val state = rememberLazyGridState() - if (fab != null) - syncFABWithCompose(state, fab) - - LazyVerticalGrid( - columns = GridCells.Adaptive(if (cardType != COMPRESSED) size else 400.dp), - contentPadding = PaddingValues( - bottom = 300.dp, - start = 8.dp, - end = 8.dp, - top = 4.dp - ), - state = state, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + if (items == null) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + LibraryPager( + library = items, + setActiveCategory = setActiveCategory, + cardType = cardType, + columnsInV = columnsInV, + columnsInH = columnsInH, + hasSelected = hasSelected, + onRefresh = onRefresh, + onOpen = onOpen, + toggleSelection = toggleSelection, + toastNovel = toastNovel, + fab = fab + ) + } + } else { + ErrorContent( + stringResource(R.string.empty_library_message) + ) + } +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun LibraryPager( + library: LibraryUI, + setActiveCategory: (Int) -> Unit, + cardType: NovelCardType, + columnsInV: Int, + columnsInH: Int, + hasSelected: Boolean, + onRefresh: (Int) -> Unit, + onOpen: (LibraryNovelUI) -> Unit, + toggleSelection: (LibraryNovelUI) -> Unit, + toastNovel: ((LibraryNovelUI) -> Unit)?, + fab: EFabMaintainer? +) { + val scope = rememberCoroutineScope() + val state = rememberPagerState() + LaunchedEffect(state.currentPage) { + setActiveCategory(library.categories[state.currentPage].id) + } + + Column(Modifier.fillMaxWidth()) { + if (!(library.categories.size == 1 && library.categories.first().id == 0)) { + ScrollableTabRow( + selectedTabIndex = state.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset(state, tabPositions) + ) + }, + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.1F), + edgePadding = 0.dp, ) { - fun onClick(item: LibraryNovelUI) { - if (hasSelected) - toggleSelection(item) - else onOpen(item) + library.categories.forEachIndexed { index, category -> + Tab( + text = { Text(category.name) }, + selected = state.currentPage == index, + onClick = { + scope.launch { + state.animateScrollToPage(index) + } + }, + ) } - - fun onLongClick(item: LibraryNovelUI) { - if (!hasSelected) - toggleSelection(item) + } + } + HorizontalPager( + count = library.categories.size, + state = state, + modifier = Modifier.fillMaxSize() + ) { + val id by derivedStateOf { + library.categories[it].id + } + val items by produceState(emptyList(), library, it, id) { + value = onIO { + library.novels[id].orEmpty() } - items( - items, - key = { it.hashCode() } - ) { item -> - val onClickBadge = if (toastNovel != null) { - { toastNovel(item) } - } else null - when (cardType) { - NORMAL -> { - NovelCardNormalContent( - item.title, - item.imageURL, - onClick = { - onClick(item) - }, - onLongClick = { - onLongClick(item) - }, - overlay = { - if (item.unread > 0) - Badge( - Modifier - .align(Alignment.TopStart) - .padding(top = 4.dp, start = 4.dp), - text = item.unread.toString(), - onClick = onClickBadge - ) - }, - isSelected = item.isSelected - ) - } - COMPRESSED -> { - NovelCardCompressedContent( - item.title, - item.imageURL, - onClick = { - onClick(item) - }, - onLongClick = { - onLongClick(item) - }, - overlay = { - if (item.unread > 0) - Badge( - Modifier.padding(8.dp), - text = item.unread.toString(), - onClick = onClickBadge - ) - }, - isSelected = item.isSelected - ) - } - COZY -> { - NovelCardCozyContent( - item.title, - item.imageURL, - onClick = { - onClick(item) - }, - onLongClick = { - onLongClick(item) - }, - overlay = { - if (item.unread > 0) - Badge( - Modifier - .align(Alignment.TopStart) - .padding(top = 4.dp, start = 4.dp), - text = item.unread.toString(), - onClick = onClickBadge - ) - }, - isSelected = item.isSelected - ) - } + } + LibraryCategory( + items = items, + cardType = cardType, + columnsInV = columnsInV, + columnsInH = columnsInH, + hasSelected = hasSelected, + onRefresh = { onRefresh(id) }, + onOpen = onOpen, + toggleSelection = toggleSelection, + toastNovel = toastNovel, + fab = fab + ) + } + } +} + +@Composable +fun LibraryCategory( + items: List, + cardType: NovelCardType, + columnsInV: Int, + columnsInH: Int, + hasSelected: Boolean, + onRefresh: () -> Unit, + onOpen: (LibraryNovelUI) -> Unit, + toggleSelection: (LibraryNovelUI) -> Unit, + toastNovel: ((LibraryNovelUI) -> Unit)?, + fab: EFabMaintainer? +) { + SwipeRefresh( + state = SwipeRefreshState(false), + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + val w = LocalConfiguration.current.screenWidthDp + val o = LocalConfiguration.current.orientation + + val size = + (w / when (o) { + Configuration.ORIENTATION_LANDSCAPE -> columnsInH + else -> columnsInV + }).dp - 16.dp + + + val state = rememberLazyGridState() + if (fab != null) + syncFABWithCompose(state, fab) + + LazyVerticalGrid( + columns = GridCells.Adaptive(if (cardType != COMPRESSED) size else 400.dp), + contentPadding = PaddingValues( + bottom = 300.dp, + start = 8.dp, + end = 8.dp, + top = 4.dp + ), + state = state, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + fun onClick(item: LibraryNovelUI) { + if (hasSelected) + toggleSelection(item) + else onOpen(item) + } + + fun onLongClick(item: LibraryNovelUI) { + if (!hasSelected) + toggleSelection(item) + } + items( + items, + key = { it.hashCode() } + ) { item -> + val onClickBadge = if (toastNovel != null) { + { toastNovel(item) } + } else null + when (cardType) { + NORMAL -> { + NovelCardNormalContent( + item.title, + item.imageURL, + onClick = { + onClick(item) + }, + onLongClick = { + onLongClick(item) + }, + overlay = { + if (item.unread > 0) + Badge( + Modifier + .align(Alignment.TopStart) + .padding(top = 4.dp, start = 4.dp), + text = item.unread.toString(), + onClick = onClickBadge + ) + }, + isSelected = item.isSelected + ) + } + COMPRESSED -> { + NovelCardCompressedContent( + item.title, + item.imageURL, + onClick = { + onClick(item) + }, + onLongClick = { + onLongClick(item) + }, + overlay = { + if (item.unread > 0) + Badge( + Modifier.padding(8.dp), + text = item.unread.toString(), + onClick = onClickBadge + ) + }, + isSelected = item.isSelected + ) + } + COZY -> { + NovelCardCozyContent( + item.title, + item.imageURL, + onClick = { + onClick(item) + }, + onLongClick = { + onLongClick(item) + }, + overlay = { + if (item.unread > 0) + Badge( + Modifier + .align(Alignment.TopStart) + .padding(top = 4.dp, start = 4.dp), + text = item.unread.toString(), + onClick = onClickBadge + ) + }, + isSelected = item.isSelected + ) } } } } - } else { - ErrorContent( - stringResource(R.string.empty_library_message) - ) } - } @Composable diff --git a/android/src/main/java/app/shosetsu/android/ui/more/MoreController.kt b/android/src/main/java/app/shosetsu/android/ui/more/MoreController.kt index 2909f2fe0c..6e6a939535 100644 --- a/android/src/main/java/app/shosetsu/android/ui/more/MoreController.kt +++ b/android/src/main/java/app/shosetsu/android/ui/more/MoreController.kt @@ -170,6 +170,12 @@ fun MoreContent( } } + item { + MoreItemContent(R.string.categories, R.drawable.ic_baseline_label_24) { + pushController(R.id.action_moreController_to_categoriesController, true) + } + } + item { MoreItemContent(R.string.styles, R.drawable.ic_baseline_style_24) { showStyleBar() diff --git a/android/src/main/java/app/shosetsu/android/ui/novel/NovelController.kt b/android/src/main/java/app/shosetsu/android/ui/novel/NovelController.kt index 44c910acb7..517b35d503 100644 --- a/android/src/main/java/app/shosetsu/android/ui/novel/NovelController.kt +++ b/android/src/main/java/app/shosetsu/android/ui/novel/NovelController.kt @@ -49,6 +49,7 @@ import app.shosetsu.android.ui.migration.MigrationController.Companion.TARGETS_B import app.shosetsu.android.view.compose.ImageLoadingError import app.shosetsu.android.view.compose.LazyColumnScrollbar import app.shosetsu.android.view.compose.ShosetsuCompose +import app.shosetsu.android.view.compose.TextButton import app.shosetsu.android.view.compose.coverRatio import app.shosetsu.android.view.controller.ShosetsuController import app.shosetsu.android.view.controller.base.ExtendedFABController @@ -56,6 +57,7 @@ import app.shosetsu.android.view.controller.base.ExtendedFABController.EFabMaint import app.shosetsu.android.view.controller.base.syncFABWithCompose import app.shosetsu.android.view.openQRCodeShareDialog import app.shosetsu.android.view.openShareMenu +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.ChapterUI import app.shosetsu.android.view.uimodels.model.NovelUI import app.shosetsu.android.viewmodel.abstracted.ANovelViewModel @@ -270,6 +272,10 @@ class NovelController : ShosetsuController(), viewModel.downloadAllChapters() true } + R.id.set_categories -> { + categoriesDialogOpen = true + true + } else -> false } @@ -369,10 +375,16 @@ class NovelController : ShosetsuController(), runBlocking { menu.findItem(R.id.source_migrate).isVisible = viewModel.isBookmarked().first() + menu.findItem(R.id.set_categories).isVisible = viewModel.categories.first().isNotEmpty() } } private var state = LazyListState(0) + private var categoriesDialogOpen by mutableStateOf(false) + + private fun setCategories(categories: IntArray) { + viewModel.setNovelCategories(categories).firstLa(this, catch = {}) {} + } private fun toggleBookmark() { viewModel.toggleNovelBookmark().firstLa(this@NovelController, catch = {}) { @@ -414,6 +426,8 @@ class NovelController : ShosetsuController(), val isRefreshing by viewModel.isRefreshing.collectAsState(false) val hasSelected by viewModel.hasSelected.collectAsState(false) val itemAt by viewModel.itemIndex.collectAsState(0) + val categories by viewModel.categories.collectAsState(emptyList()) + val novelCategories by viewModel.novelCategories.collectAsState(emptyList()) activity?.invalidateOptionsMenu() // If the data is not present, loads it @@ -439,6 +453,8 @@ class NovelController : ShosetsuController(), else displayOfflineSnackBar() }, openWebView = ::openWebView, + categories = categories, + setCategoriesDialogOpen = { categoriesDialogOpen = true }, toggleBookmark = ::toggleBookmark, openFilter = ::openFilterMenu, openChapterJump = ::openChapterJumpDialog, @@ -468,6 +484,14 @@ class NovelController : ShosetsuController(), hasSelected = hasSelected, state = state ) + + if (categoriesDialogOpen) + CategoriesDialog( + onDismissRequest = { categoriesDialogOpen = false }, + categories = categories, + novelCategories = novelCategories, + setCategories = ::setCategories + ) } } } @@ -718,6 +742,8 @@ fun PreviewNovelInfoContent() { isRefreshing = false, onRefresh = {}, openWebView = {}, + emptyList(), + {}, toggleBookmark = {}, openFilter = {}, openChapterJump = {}, @@ -750,6 +776,8 @@ fun NovelInfoContent( isRefreshing: Boolean, onRefresh: () -> Unit, openWebView: () -> Unit, + categories: List, + setCategoriesDialogOpen: (Boolean) -> Unit, toggleBookmark: () -> Unit, openFilter: () -> Unit, openChapterJump: () -> Unit, @@ -785,6 +813,8 @@ fun NovelInfoContent( NovelInfoHeaderContent( novelInfo = novelInfo, openWebview = openWebView, + categories = categories, + setCategoriesDialogOpen = setCategoriesDialogOpen, toggleBookmark = toggleBookmark, openChapterJump = openChapterJump, openFilter = openFilter, @@ -1039,6 +1069,8 @@ fun PreviewHeaderContent() { info, chapterCount = 0, {}, + emptyList(), + {}, {}, {}, {} @@ -1077,9 +1109,11 @@ fun NovelInfoHeaderContent( novelInfo: NovelUI, chapterCount: Int, openWebview: () -> Unit, + categories: List, toggleBookmark: () -> Unit, openFilter: () -> Unit, - openChapterJump: () -> Unit + openChapterJump: () -> Unit, + setCategoriesDialogOpen: (Boolean) -> Unit, ) { var isCoverClicked: Boolean by remember { mutableStateOf(false) } if (isCoverClicked) @@ -1211,7 +1245,16 @@ fun NovelInfoHeaderContent( verticalAlignment = Alignment.CenterVertically ) { TextButton( - onClick = toggleBookmark, + onClick = { + if (novelInfo.bookmarked || categories.isEmpty()) { + toggleBookmark() + } else { + setCategoriesDialogOpen(true) + } + }, + onLongClick = { + setCategoriesDialogOpen(true) + }, modifier = Modifier .padding(vertical = 8.dp, horizontal = 4.dp) .weight(1F) @@ -1402,4 +1445,63 @@ fun ExpandedText( Icon(painterResource(drawable.expand_more), contentDescription = stringResource(string.more)) else Icon(painterResource(drawable.expand_less), contentDescription = stringResource(string.less)) } +} + +@Composable +fun CategoriesDialog( + onDismissRequest: () -> Unit, + categories: List, + novelCategories: List, + setCategories: (IntArray) -> Unit +) { + val selectedCategories = remember(novelCategories) { + novelCategories.toMutableStateList() + } + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + setCategories(selectedCategories.toIntArray()) + onDismissRequest() + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { + Text(stringResource(string.set_categories)) + }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + categories.filterNot { it.id == 0 }.forEach { + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + if (it.id in selectedCategories) { + selectedCategories -= it.id + } else { + selectedCategories += it.id + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = it.id in selectedCategories, + onCheckedChange = null, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text(it.name) + } + } + } + } + ) } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/ui/settings/sub/UpdateSettings.kt b/android/src/main/java/app/shosetsu/android/ui/settings/sub/UpdateSettings.kt index c1dd715f0d..946ad7e4d7 100644 --- a/android/src/main/java/app/shosetsu/android/ui/settings/sub/UpdateSettings.kt +++ b/android/src/main/java/app/shosetsu/android/ui/settings/sub/UpdateSettings.kt @@ -1,29 +1,57 @@ package app.shosetsu.android.ui.settings.sub +import android.content.Context import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TriStateCheckbox import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp import app.shosetsu.android.common.SettingKey +import app.shosetsu.android.common.StringSetKey +import app.shosetsu.android.common.enums.TriStateState +import app.shosetsu.android.common.ext.launchIO import app.shosetsu.android.common.ext.viewModel import app.shosetsu.android.view.compose.ShosetsuCompose +import app.shosetsu.android.view.compose.TextButton +import app.shosetsu.android.view.compose.setting.ButtonSettingContent import app.shosetsu.android.view.compose.setting.HeaderSettingContent import app.shosetsu.android.view.compose.setting.SliderSettingContent import app.shosetsu.android.view.compose.setting.SwitchSettingContent import app.shosetsu.android.view.controller.ShosetsuController +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.viewmodel.abstracted.settings.AUpdateSettingsViewModel import app.shosetsu.android.BuildConfig import app.shosetsu.android.R +import kotlinx.coroutines.flow.map /* * This file is part of shosetsu. @@ -121,6 +149,14 @@ fun UpdateSettingsContent(viewModel: AUpdateSettingsViewModel) { ) } + item { + viewModel.LibraryUpdateCategories( + stringResource(R.string.settings_update_novel_categories_update), + SettingKey.IncludeCategoriesInUpdate, + SettingKey.ExcludedCategoriesInUpdate + ) + } + item { SwitchSettingContent( stringResource(R.string.settings_update_novel_on_update_title), @@ -132,6 +168,14 @@ fun UpdateSettingsContent(viewModel: AUpdateSettingsViewModel) { ) } + item { + viewModel.LibraryUpdateCategories( + stringResource(R.string.settings_update_novel_categories_download), + SettingKey.IncludeCategoriesToDownload, + SettingKey.ExcludedCategoriesToDownload + ) + } + item { SwitchSettingContent( stringResource(R.string.settings_update_novel_only_ongoing_title), @@ -261,4 +305,159 @@ fun UpdateSettingsContent(viewModel: AUpdateSettingsViewModel) { ) } } +} + +@Composable +private fun AUpdateSettingsViewModel.LibraryUpdateCategories( + title: String, + includeKey: StringSetKey, + excludeKey: StringSetKey +) { + val categories by categories.collectAsState(emptyList()) + val includedCategoryIds by remember(includeKey) { + settingsRepo.getStringSetFlow(includeKey) + .map { it.map(String::toInt) } + }.collectAsState(emptyList()) + val excludedCategoryIds by remember(excludeKey) { + settingsRepo.getStringSetFlow(excludeKey) + .map { it.map(String::toInt) } + }.collectAsState(emptyList()) + + var dialogOpen by remember(includeKey, excludeKey) { + mutableStateOf(false) + } + val context = LocalContext.current + ButtonSettingContent( + title = title, + description = derivedStateOf { + getCategorySelectDescription(context, categories, includedCategoryIds, excludedCategoryIds) + }.value, + buttonText = stringResource(R.string.settings_update_novel_categories_open) + ) { + dialogOpen = true + } + if (dialogOpen) { + CategoriesSelectDialog( + categories = categories, + onDismissRequest = { dialogOpen = false }, + includedCategoryIds = includedCategoryIds, + excludedCategoryIds = excludedCategoryIds, + onSelect = { included, excluded -> + launchIO { + settingsRepo.setStringSet( + includeKey, + included.map { it.toString() }.toSet() + ) + settingsRepo.setStringSet( + excludeKey, + excluded.map { it.toString() }.toSet() + ) + } + } + ) + } +} + +@Composable +fun CategoriesSelectDialog( + categories: List, + onDismissRequest: () -> Unit, + includedCategoryIds: List, + excludedCategoryIds: List, + onSelect: (included: List, excluded: List) -> Unit +) { + val state = remember(includedCategoryIds, excludedCategoryIds) { + mutableStateMapOf().apply { + putAll(includedCategoryIds.map { it to TriStateState.CHECKED }) + putAll(excludedCategoryIds.map { it to TriStateState.IGNORED }) + } + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(stringResource(R.string.categories)) + }, + confirmButton = { + TextButton( + onClick = { + onSelect( + state.filter { it.value == TriStateState.CHECKED } + .map { it.key }, + state.filter { it.value == TriStateState.IGNORED } + .map { it.key }, + ) + onDismissRequest() + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + categories.forEach { + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + state[it.id] = + (state[it.id] ?: TriStateState.UNCHECKED).cycle(false) + }, + verticalAlignment = Alignment.CenterVertically + ) { + TriStateCheckbox( + state = when (state[it.id]) { + TriStateState.IGNORED -> ToggleableState.Indeterminate + TriStateState.CHECKED -> ToggleableState.On + else -> ToggleableState.Off + }, + onClick = null, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text(it.name) + } + } + } + } + ) +} + +fun getCategorySelectDescription( + context: Context, + categories: List, + includedCategoryIds: List, + excludedCategoryIds: List, +): String { + val includedCategories = includedCategoryIds + .mapNotNull { id -> categories.find { it.id == id } } + .sortedBy { it.order } + val excludedCategories = excludedCategoryIds + .mapNotNull { id -> categories.find { it.id == id } } + .sortedBy { it.order } + + val allExcluded = excludedCategories.size == categories.size + + val includedItemsText = when { + // Some selected, but not all + includedCategories.isNotEmpty() && includedCategories.size != categories.size -> includedCategories.joinToString { it.name } + // All explicitly selected + includedCategories.size == categories.size -> context.getString(R.string.all) + allExcluded -> context.getString(R.string.none) + else -> context.getString(R.string.all) + } + val excludedItemsText = when { + excludedCategories.isEmpty() -> context.getString(R.string.none) + allExcluded -> context.getString(R.string.all) + else -> excludedCategories.joinToString { it.name } + } + return buildString { + append(context.getString(R.string.settings_update_novel_include_categories, includedItemsText)) + appendLine() + append(context.getString(R.string.settings_update_novel_exclude_categories, excludedItemsText)) + } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/ui/updates/UpdatesController.kt b/android/src/main/java/app/shosetsu/android/ui/updates/UpdatesController.kt index 0b3953ba27..356d01fd9c 100644 --- a/android/src/main/java/app/shosetsu/android/ui/updates/UpdatesController.kt +++ b/android/src/main/java/app/shosetsu/android/ui/updates/UpdatesController.kt @@ -112,7 +112,7 @@ class ComposeUpdatesController : ShosetsuController(), HomeFragment { fun onRefresh() { if (viewModel.isOnline()) - viewModel.startUpdateManager() + viewModel.startUpdateManager(-1) else displayOfflineSnackBar(R.string.generic_error_cannot_update_library_offline) } } diff --git a/android/src/main/java/app/shosetsu/android/view/compose/Button.kt b/android/src/main/java/app/shosetsu/android/view/compose/Button.kt new file mode 100644 index 0000000000..0e220ec74d --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/view/compose/Button.kt @@ -0,0 +1,115 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.view.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ButtonElevation +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +@Composable +fun TextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = null, + shape: Shape = MaterialTheme.shapes.small, + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, + content: @Composable RowScope.() -> Unit, +) = + Button( + onClick = onClick, + modifier = modifier, + onLongClick = onLongClick, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding, + content = content, + ) + +@Composable +fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(), + shape: Shape = MaterialTheme.shapes.small, + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit +) { + val contentColor by colors.contentColor(enabled) + + Surface( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + enabled = enabled, + shape = shape, + color = colors.backgroundColor(enabled).value, + contentColor = contentColor.copy(alpha = 1f), + border = border, + elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp, + interactionSource = interactionSource, + ) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + ProvideTextStyle(value = MaterialTheme.typography.button) { + Row( + Modifier.defaultMinSize( + minWidth = ButtonDefaults.MinWidth, + minHeight = ButtonDefaults.MinHeight, + ) + .padding(contentPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } + } + } +} diff --git a/android/src/main/java/app/shosetsu/android/view/compose/Modifier.kt b/android/src/main/java/app/shosetsu/android/view/compose/Modifier.kt new file mode 100644 index 0000000000..35c265b57a --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/view/compose/Modifier.kt @@ -0,0 +1,78 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.view.compose + +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumTouchTargetEnforcement +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.DpSize +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterialApi::class) +@Suppress("ModifierInspectorInfo") +fun Modifier.minimumTouchTargetSize(): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "minimumTouchTargetSize" + properties["README"] = "Adds outer padding to measure at least 48.dp (default) in " + + "size to disambiguate touch interactions if the element would measure smaller" + }, +) { + if (LocalMinimumTouchTargetEnforcement.current) { + val size = LocalViewConfiguration.current.minimumTouchTargetSize + MinimumTouchTargetModifier(size) + } else { + Modifier + } +} + +private class MinimumTouchTargetModifier(val size: DpSize) : LayoutModifier { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + + // Be at least as big as the minimum dimension in both dimensions + val width = maxOf(placeable.width, size.width.roundToPx()) + val height = maxOf(placeable.height, size.height.roundToPx()) + + return layout(width, height) { + val centerX = ((width - placeable.width) / 2f).roundToInt() + val centerY = ((height - placeable.height) / 2f).roundToInt() + placeable.place(centerX, centerY) + } + } + + override fun equals(other: Any?): Boolean { + val otherModifier = other as? MinimumTouchTargetModifier ?: return false + return size == otherModifier.size + } + + override fun hashCode(): Int { + return size.hashCode() + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/view/compose/Surface.kt b/android/src/main/java/app/shosetsu/android/view/compose/Surface.kt new file mode 100644 index 0000000000..055630e34f --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/view/compose/Surface.kt @@ -0,0 +1,126 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.view.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.material.ElevationOverlay +import androidx.compose.material.LocalAbsoluteElevation +import androidx.compose.material.LocalContentColor +import androidx.compose.material.LocalElevationOverlay +import androidx.compose.material.MaterialTheme +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +@NonRestartableComposable +fun Surface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + shape: Shape = RectangleShape, + color: Color = MaterialTheme.colors.surface, + contentColor: Color = contentColorFor(color), + border: BorderStroke? = null, + elevation: Dp = 0.dp, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + indication: Indication? = LocalIndication.current, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + content: @Composable () -> Unit, +) { + val absoluteElevation = LocalAbsoluteElevation.current + elevation + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalAbsoluteElevation provides absoluteElevation + ) { + Box( + modifier + .minimumTouchTargetSize() + .surface( + shape = shape, + backgroundColor = surfaceColorAtElevation( + color = color, + elevationOverlay = LocalElevationOverlay.current, + absoluteElevation = absoluteElevation + ), + border = border, + elevation = elevation + ) + .then( + Modifier.combinedClickable( + interactionSource = interactionSource, + indication = indication, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + onLongClick = onLongClick + ) + ), + propagateMinConstraints = true + ) { + content() + } + } +} + +private fun Modifier.surface( + shape: Shape, + backgroundColor: Color, + border: BorderStroke?, + elevation: Dp +) = this.shadow(elevation, shape, clip = false) + .then(if (border != null) Modifier.border(border, shape) else Modifier) + .background(color = backgroundColor, shape = shape) + .clip(shape) + +@Composable +private fun surfaceColorAtElevation( + color: Color, + elevationOverlay: ElevationOverlay?, + absoluteElevation: Dp +): Color { + return if (color == MaterialTheme.colors.surface && elevationOverlay != null) { + elevationOverlay.apply(color, absoluteElevation) + } else { + color + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/view/compose/setting/GenericRightSettingLayout.kt b/android/src/main/java/app/shosetsu/android/view/compose/setting/GenericRightSettingLayout.kt index f64af9093d..9ca1523ff7 100644 --- a/android/src/main/java/app/shosetsu/android/view/compose/setting/GenericRightSettingLayout.kt +++ b/android/src/main/java/app/shosetsu/android/view/compose/setting/GenericRightSettingLayout.kt @@ -40,6 +40,7 @@ fun GenericRightSettingLayout( Row( modifier = modifier then Modifier .defaultMinSize(minHeight = 56.dp) + .fillMaxWidth() .let { if (onClick != null) it.clickable(onClick = onClick, enabled = enabled) diff --git a/android/src/main/java/app/shosetsu/android/view/uimodels/model/CategoryUI.kt b/android/src/main/java/app/shosetsu/android/view/uimodels/model/CategoryUI.kt new file mode 100644 index 0000000000..bc22b25e8e --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/view/uimodels/model/CategoryUI.kt @@ -0,0 +1,41 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.view.uimodels.model + +import androidx.compose.runtime.Immutable +import app.shosetsu.android.domain.model.local.CategoryEntity +import app.shosetsu.android.dto.Convertible + +@Immutable +data class CategoryUI( + val id: Int, + val name: String, + val order: Int +) : Convertible { + override fun convertTo() = CategoryEntity( + id = id, + name = name, + order = order + ) + + companion object { + val default: () -> CategoryUI + get() = { CategoryUI(0, "Default", 0) } + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryNovelUI.kt b/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryNovelUI.kt index 42ff052928..34c76895c0 100644 --- a/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryNovelUI.kt +++ b/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryNovelUI.kt @@ -3,6 +3,7 @@ package app.shosetsu.android.view.uimodels.model import androidx.compose.runtime.Immutable import app.shosetsu.android.domain.model.local.LibraryNovelEntity import app.shosetsu.android.dto.Convertible +import app.shosetsu.lib.Novel @Immutable data class LibraryNovelUI( @@ -15,6 +16,8 @@ data class LibraryNovelUI( val authors: List, val artists: List, val tags: List, + val status: Novel.Status, + val category: Int, val isSelected: Boolean = false ) : Convertible { override fun convertTo(): LibraryNovelEntity = @@ -27,6 +30,8 @@ data class LibraryNovelUI( genres, authors, artists, - tags + tags, + status, + category ) } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryUI.kt b/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryUI.kt new file mode 100644 index 0000000000..47be381be7 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/view/uimodels/model/LibraryUI.kt @@ -0,0 +1,24 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.view.uimodels.model + +data class LibraryUI( + val categories: List, + val novels: Map> +) \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACatalogViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACatalogViewModel.kt index 8ab96f1d93..5403b1da3a 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACatalogViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACatalogViewModel.kt @@ -2,6 +2,7 @@ package app.shosetsu.android.viewmodel.abstracted import androidx.paging.PagingData import app.shosetsu.android.common.enums.NovelCardType +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.catlog.ACatalogNovelUI import app.shosetsu.android.viewmodel.base.ShosetsuViewModel import app.shosetsu.lib.Filter @@ -65,6 +66,8 @@ abstract class ACatalogViewModel : abstract val columnsInH: Flow abstract val columnsInV: Flow + abstract val categories: Flow> + /** * Sets the [IExtension] * @@ -88,7 +91,7 @@ abstract class ACatalogViewModel : * Bookmarks and loads the specific novel in the background * @param novelID ID of novel to load */ - abstract fun backgroundNovelAdd(novelID: Int): Flow + abstract fun backgroundNovelAdd(novelID: Int, categories: IntArray): Flow enum class BackgroundNovelAddProgress { ADDING, ADDED } diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACategoriesViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACategoriesViewModel.kt new file mode 100644 index 0000000000..f6fdb7e375 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ACategoriesViewModel.kt @@ -0,0 +1,49 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.viewmodel.abstracted + +import app.shosetsu.android.view.uimodels.model.CategoryUI +import app.shosetsu.android.viewmodel.base.ShosetsuViewModel +import app.shosetsu.android.viewmodel.base.SubscribeViewModel +import kotlinx.coroutines.flow.Flow + +abstract class ACategoriesViewModel : SubscribeViewModel>, + ShosetsuViewModel() { + /** + * Adds a category via a string the user provides + * + * @param name The name of the category + */ + abstract fun addCategory(name: String): Flow + + /** + * Remove the category from the app + */ + abstract fun remove(categoryUI: CategoryUI): Flow + + /** + * Move the category up one + */ + abstract fun moveUp(categoryUI: CategoryUI): Flow + + /** + * Move the category down one + */ + abstract fun moveDown(categoryUI: CategoryUI): Flow +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ALibraryViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ALibraryViewModel.kt index 5133b58b29..6e8ec7d72a 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ALibraryViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ALibraryViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.state.ToggleableState import app.shosetsu.android.common.enums.NovelCardType import app.shosetsu.android.common.enums.NovelSortType import app.shosetsu.android.view.uimodels.model.LibraryNovelUI +import app.shosetsu.android.view.uimodels.model.LibraryUI import app.shosetsu.android.viewmodel.base.IsOnlineCheckViewModel import app.shosetsu.android.viewmodel.base.ShosetsuViewModel import app.shosetsu.android.viewmodel.base.StartUpdateManagerViewModel @@ -35,7 +36,7 @@ import kotlinx.coroutines.flow.Flow * @author github.com/doomsdayrs */ abstract class ALibraryViewModel : - SubscribeViewModel>, + SubscribeViewModel, ShosetsuViewModel(), IsOnlineCheckViewModel, StartUpdateManagerViewModel { @@ -86,6 +87,8 @@ abstract class ALibraryViewModel : abstract fun resetSortAndFilters() abstract fun setViewType(cardType: NovelCardType) + abstract fun setCategories(categories: IntArray) + abstract fun removeSelectedFromLibrary() abstract fun getSelectedIds(): Flow @@ -98,4 +101,7 @@ abstract class ALibraryViewModel : abstract val queryFlow: Flow abstract fun setQuery(s: String) + abstract val activeCategory: Flow + abstract fun setActiveCategory(category: Int) + } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ANovelViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ANovelViewModel.kt index aa9f409196..5a09a5bced 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ANovelViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/ANovelViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.ImageBitmap import app.shosetsu.android.common.enums.ReadingStatus import app.shosetsu.android.view.uimodels.NovelSettingUI +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.ChapterUI import app.shosetsu.android.view.uimodels.model.NovelUI import app.shosetsu.android.viewmodel.base.IsOnlineCheckViewModel @@ -56,9 +57,17 @@ abstract class ANovelViewModel abstract val novelSettingFlow: Flow + abstract val categories: Flow> + abstract val novelCategories: Flow> + /** Set's the value to be loaded */ abstract fun setNovelID(novelID: Int) + /** + * Set the categories of the novel + */ + abstract fun setNovelCategories(categories: IntArray): Flow + /** * Toggles the bookmark of this ui * @return ToggleBookmarkResponse of what the UI should react with diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/settings/AUpdateSettingsViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/settings/AUpdateSettingsViewModel.kt index caac870477..b4656d6247 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/settings/AUpdateSettingsViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/abstracted/settings/AUpdateSettingsViewModel.kt @@ -1,6 +1,8 @@ package app.shosetsu.android.viewmodel.abstracted.settings import app.shosetsu.android.domain.repository.base.ISettingsRepository +import app.shosetsu.android.view.uimodels.model.CategoryUI +import kotlinx.coroutines.flow.Flow /* * This file is part of shosetsu. @@ -24,4 +26,7 @@ import app.shosetsu.android.domain.repository.base.ISettingsRepository * 31 / 08 / 2020 */ abstract class AUpdateSettingsViewModel(iSettingsRepository: ISettingsRepository) : - ASubSettingsViewModel(iSettingsRepository) \ No newline at end of file + ASubSettingsViewModel(iSettingsRepository) { + + abstract val categories: Flow> +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/base/StartUpdateManagerViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/base/StartUpdateManagerViewModel.kt index 14a56de2a5..362d6468ec 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/base/StartUpdateManagerViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/base/StartUpdateManagerViewModel.kt @@ -24,5 +24,5 @@ package app.shosetsu.android.viewmodel.base interface StartUpdateManagerViewModel { /** Starts the update manager, Will not start if it is running */ - fun startUpdateManager() + fun startUpdateManager(categoryID: Int) } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/CatalogViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/CatalogViewModel.kt index 9311f6cdf9..91f8123eb1 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/impl/CatalogViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/CatalogViewModel.kt @@ -10,13 +10,16 @@ import app.shosetsu.android.common.ext.launchIO import app.shosetsu.android.common.ext.logI import app.shosetsu.android.common.utils.copy import app.shosetsu.android.domain.usecases.NovelBackgroundAddUseCase +import app.shosetsu.android.domain.usecases.SetNovelCategoriesUseCase import app.shosetsu.android.domain.usecases.get.GetCatalogueListingDataUseCase import app.shosetsu.android.domain.usecases.get.GetCatalogueQueryDataUseCase +import app.shosetsu.android.domain.usecases.get.GetCategoriesUseCase import app.shosetsu.android.domain.usecases.get.GetExtensionUseCase import app.shosetsu.android.domain.usecases.load.LoadNovelUIColumnsHUseCase import app.shosetsu.android.domain.usecases.load.LoadNovelUIColumnsPUseCase import app.shosetsu.android.domain.usecases.load.LoadNovelUITypeUseCase import app.shosetsu.android.domain.usecases.settings.SetNovelUITypeUseCase +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.catlog.ACatalogNovelUI import app.shosetsu.android.viewmodel.abstracted.ACatalogViewModel import app.shosetsu.lib.Filter @@ -57,6 +60,8 @@ class CatalogViewModel( private val loadNovelUIColumnsHUseCase: LoadNovelUIColumnsHUseCase, private val loadNovelUIColumnsPUseCase: LoadNovelUIColumnsPUseCase, private val setNovelUIType: SetNovelUITypeUseCase, + private val getCategoriesUseCase: GetCategoriesUseCase, + private val setNovelCategoriesUseCase: SetNovelCategoriesUseCase ) : ACatalogViewModel() { private val queryFlow: MutableStateFlow by lazy { MutableStateFlow(null) } @@ -223,10 +228,12 @@ class CatalogViewModel( filterItemsFlow.first().forEach { filter -> resetFilter(filter) } } - override fun backgroundNovelAdd(novelID: Int): Flow = + override fun backgroundNovelAdd(novelID: Int, categories: IntArray): Flow = flow { emit(BackgroundNovelAddProgress.ADDING) backgroundAddUseCase(novelID) + if (categories.isNotEmpty()) + setNovelCategoriesUseCase(novelID, categories) emit(BackgroundNovelAddProgress.ADDED) }.onIO() @@ -316,6 +323,10 @@ class CatalogViewModel( loadNovelUIColumnsPUseCase().onIO() } + override val categories: Flow> by lazy { + getCategoriesUseCase() + } + override fun destroy() { extensionIDFlow.value = -1 resetView() diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/CategoriesViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/CategoriesViewModel.kt new file mode 100644 index 0000000000..bed4a15699 --- /dev/null +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/CategoriesViewModel.kt @@ -0,0 +1,60 @@ +/* + * This file is part of Shosetsu. + * + * Shosetsu is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Shosetsu is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Shosetsu. If not, see . + * + */ + +package app.shosetsu.android.viewmodel.impl + +import app.shosetsu.android.domain.usecases.AddCategoryUseCase +import app.shosetsu.android.domain.usecases.DeleteCategoryUseCase +import app.shosetsu.android.domain.usecases.MoveCategoryUseCase +import app.shosetsu.android.domain.usecases.get.GetCategoriesUseCase +import app.shosetsu.android.view.uimodels.model.CategoryUI +import app.shosetsu.android.viewmodel.abstracted.ACategoriesViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class CategoriesViewModel( + private val getCategoriesUseCase: GetCategoriesUseCase, + private val addCategoryUseCase: AddCategoryUseCase, + private val deleteCategoryUseCase: DeleteCategoryUseCase, + private val moveCategoryUseCase: MoveCategoryUseCase +) : ACategoriesViewModel() { + + override val liveData: Flow> by lazy { + getCategoriesUseCase() + } + + override fun addCategory(name: String): Flow = flow { + addCategoryUseCase(name) + emit(Unit) + } + + override fun remove(categoryUI: CategoryUI): Flow = flow { + deleteCategoryUseCase(categoryUI) + emit(Unit) + } + + override fun moveUp(categoryUI: CategoryUI) = flow { + moveCategoryUseCase(categoryUI, categoryUI.order + 1) + emit(Unit) + } + + override fun moveDown(categoryUI: CategoryUI) = flow { + moveCategoryUseCase(categoryUI, categoryUI.order - 1) + emit(Unit) + } +} \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/LibraryViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/LibraryViewModel.kt index bfd884a1bf..c9bc601d23 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/impl/LibraryViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/LibraryViewModel.kt @@ -18,6 +18,7 @@ package app.shosetsu.android.viewmodel.impl */ import androidx.compose.ui.state.ToggleableState +import androidx.lifecycle.viewModelScope import app.shosetsu.android.common.enums.InclusionState import app.shosetsu.android.common.enums.InclusionState.EXCLUDE import app.shosetsu.android.common.enums.InclusionState.INCLUDE @@ -27,19 +28,19 @@ import app.shosetsu.android.common.ext.launchIO import app.shosetsu.android.common.ext.logE import app.shosetsu.android.common.utils.copy import app.shosetsu.android.domain.usecases.IsOnlineUseCase -import app.shosetsu.android.domain.usecases.load.LoadLibraryUseCase -import app.shosetsu.android.domain.usecases.load.LoadNovelUIBadgeToastUseCase -import app.shosetsu.android.domain.usecases.load.LoadNovelUIColumnsHUseCase -import app.shosetsu.android.domain.usecases.load.LoadNovelUIColumnsPUseCase -import app.shosetsu.android.domain.usecases.load.LoadNovelUITypeUseCase +import app.shosetsu.android.domain.usecases.SetNovelsCategoriesUseCase +import app.shosetsu.android.domain.usecases.load.* import app.shosetsu.android.domain.usecases.settings.SetNovelUITypeUseCase import app.shosetsu.android.domain.usecases.start.StartUpdateWorkerUseCase import app.shosetsu.android.domain.usecases.update.UpdateBookmarkedNovelUseCase +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.LibraryNovelUI +import app.shosetsu.android.view.uimodels.model.LibraryUI import app.shosetsu.android.viewmodel.abstracted.ALibraryViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.plus import java.util.Locale.getDefault as LGD /** @@ -58,12 +59,13 @@ class LibraryViewModel( private val loadNovelUIColumnsH: LoadNovelUIColumnsHUseCase, private val loadNovelUIColumnsP: LoadNovelUIColumnsPUseCase, private val loadNovelUIBadgeToast: LoadNovelUIBadgeToastUseCase, - private val setNovelUITypeUseCase: SetNovelUITypeUseCase + private val setNovelUITypeUseCase: SetNovelUITypeUseCase, + private val setNovelsCategoriesUseCase: SetNovelsCategoriesUseCase ) : ALibraryViewModel() { - private val selectedNovels = MutableStateFlow>(emptyMap()) - private suspend fun copySelected(): HashMap = - selectedNovels.first().copy() + private val selectedNovels = MutableStateFlow>>(emptyMap()) + private fun copySelected(): HashMap> = + selectedNovels.value.copy() private fun clearSelected() { selectedNovels.value = emptyMap() @@ -71,12 +73,15 @@ class LibraryViewModel( override fun selectAll() { launchIO { - val list = liveData.first() + val category = activeCategory.value + val list = liveData.first().novels[category].orEmpty() val selection = copySelected() + val selectionCategory = selection[category].orEmpty().copy() list.forEach { - selection[it.id] = true + selectionCategory[it.id] = true } + selection[category] = selectionCategory selectedNovels.value = selection } @@ -84,11 +89,12 @@ class LibraryViewModel( override fun selectBetween() { launchIO { + val category = activeCategory.value val list = liveData.first() val selection = copySelected() - val firstSelected = list.indexOfFirst { it.isSelected } - val lastSelected = list.indexOfLast { it.isSelected } + val firstSelected = list.novels[category]?.indexOfFirst { it.isSelected } ?: -1 + val lastSelected = list.novels[category]?.indexOfLast { it.isSelected } ?: -1 if (listOf(firstSelected, lastSelected).any { it == -1 }) { logE("Received -1 index") @@ -105,9 +111,11 @@ class LibraryViewModel( return@launchIO } - list.subList(firstSelected + 1, lastSelected).forEach { - selection[it.id] = true + val selectionCategory = selection[category].orEmpty().copy() + list.novels[category].orEmpty().subList(firstSelected + 1, lastSelected).forEach { item -> + selectionCategory[item.id] = true } + selection[category] = selectionCategory selectedNovels.value = selection } @@ -117,7 +125,9 @@ class LibraryViewModel( launchIO { val selection = copySelected() - selection[item.id] = !item.isSelected + selection[item.category] = selection[item.category].orEmpty().copy().apply { + set(item.id, !item.isSelected) + } selectedNovels.value = selection } @@ -125,28 +135,31 @@ class LibraryViewModel( override fun invertSelection() { launchIO { + val category = activeCategory.value val list = liveData.first() val selection = copySelected() - list.forEach { - selection[it.id] = !it.isSelected + val selectionCategory = selection[category].orEmpty().copy() + list.novels.get(category).orEmpty().forEach { item -> + selectionCategory[item.id] = !item.isSelected } + selection[category] = selectionCategory selectedNovels.value = selection } } - private val librarySourceFlow: Flow> by lazy { libraryAsCardsUseCase() } + private val librarySourceFlow: Flow by lazy { libraryAsCardsUseCase() } override val isEmptyFlow: Flow by lazy { librarySourceFlow.map { - it.isEmpty() + it.novels.isEmpty() } } override val hasSelectionFlow: Flow by lazy { selectedNovels.mapLatest { map -> - val b = map.values.any { it } + val b = map.values.any { it.any { it.value } } hasSelection = b b } @@ -213,8 +226,9 @@ class LibraryViewModel( * * This also connects all the filtering as well */ - override val liveData: Flow> by lazy { + override val liveData: Flow by lazy { librarySourceFlow + .addDefaultCategory() .combineSelection() .combineArtistFilter() .combineAuthorFilter() @@ -223,7 +237,11 @@ class LibraryViewModel( .combineUnreadStatus() .combineSortType() .combineSortReverse() - .combineFilter().onIO() + .combineFilter() + // Replay the latest library for library re-load + .shareIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, replay = 1) + .distinctUntilChanged() + .onIO() } override val columnsInH by lazy { @@ -238,6 +256,14 @@ class LibraryViewModel( loadNovelUIBadgeToast().onIO() } + private fun Flow.addDefaultCategory() = mapLatest { + if (it.novels.containsKey(0) || (it.novels.isEmpty() && it.categories.isEmpty())) { + it.copy(categories = listOf(CategoryUI.default()) + it.categories) + } else { + it + } + } + /** * Removes the list for filtering from the [LibraryNovelUI] with the flow */ @@ -245,7 +271,7 @@ class LibraryViewModel( strip: (LibraryNovelUI) -> List ): Flow> = librarySourceFlow.mapLatest { result -> ArrayList().apply { - result.let { list -> + result.novels.flatMap { it.value }.distinctBy { it.id }.let { list -> list.forEach { ui -> strip(ui).forEach { key -> if (!contains(key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(LGD()) else it.toString() }) && key.isNotBlank()) { @@ -261,7 +287,7 @@ class LibraryViewModel( * @param flow What [Flow] to merge in updates from * @param against Return a [List] of [String] to compare against */ - private fun Flow>.applyFilterList( + private fun Flow.applyFilterList( flow: Flow>, against: (LibraryNovelUI) -> List ) = combine(flow) { list, filters -> @@ -270,25 +296,33 @@ class LibraryViewModel( filters.forEach { (s, inclusionState) -> result = when (inclusionState) { INCLUDE -> - result.filter { novelUI -> - against(novelUI).any { g -> - g.replaceFirstChar { - if (it.isLowerCase()) it.titlecase( - LGD() - ) else it.toString() - } == s + result.copy( + novels = result.novels.mapValues { + it.value.filter { novelUI -> + against(novelUI).any { g -> + g.replaceFirstChar { + if (it.isLowerCase()) it.titlecase( + LGD() + ) else it.toString() + } == s + } + } } - } + ) EXCLUDE -> - result.filterNot { novelUI -> - against(novelUI).any { g -> - g.replaceFirstChar { - if (it.isLowerCase()) it.titlecase( - LGD() - ) else it.toString() - } == s + result.copy( + novels = result.novels.mapValues { + it.value.filterNot { novelUI -> + against(novelUI).any { g -> + g.replaceFirstChar { + if (it.isLowerCase()) it.titlecase( + LGD() + ) else it.toString() + } == s + } + } } - } + ) } } result @@ -297,63 +331,79 @@ class LibraryViewModel( } } - private fun Flow>.combineGenreFilter() = + private fun Flow.combineGenreFilter() = applyFilterList(genreFilterFlow) { it.genres } - private fun Flow>.combineTagsFilter() = + private fun Flow.combineTagsFilter() = applyFilterList(tagFilterFlow) { it.tags } - private fun Flow>.combineAuthorFilter() = + private fun Flow.combineAuthorFilter() = applyFilterList(authorFilterFlow) { it.authors } - private fun Flow>.combineArtistFilter() = + private fun Flow.combineArtistFilter() = applyFilterList(artistFilterFlow) { it.artists } - private fun Flow>.combineSortReverse() = + private fun Flow.combineSortReverse() = combine(areNovelsReversedFlow) { novelResult, reversed -> - novelResult.let { list -> + novelResult.let { library -> if (reversed) - list.reversed() - else list + library.copy( + novels = library.novels.mapValues { it.value.reversed() } + ) + else library } } - private fun Flow>.combineFilter() = - combine(queryFlow) { list, query -> - list.filter { - it.title.contains(query, ignoreCase = true) - } + private fun Flow.combineFilter() = + combine(queryFlow) { library, query -> + library.copy( + novels = library.novels.mapValues { + it.value.filter { it.title.contains(query, ignoreCase = true) } + } + ) } - private fun Flow>.combineSelection() = - combine(selectedNovels) { list, query -> - list.map { - it.copy( - isSelected = query.getOrElse(it.id) { false } - ) - } + private fun Flow.combineSelection() = + combine(selectedNovels) { library, query -> + library.copy( + novels = library.novels.mapValues { (category, novels) -> + novels.map { + it.copy( + isSelected = query[category]?.get(it.id) ?: false + ) + } + } + ) } - private fun Flow>.combineSortType() = - combine(novelSortTypeFlow) { novelResult, sortType -> - novelResult.let { list -> - when (sortType) { - NovelSortType.BY_TITLE -> list.sortedBy { it.title } - NovelSortType.BY_UNREAD_COUNT -> list.sortedBy { it.unread } - NovelSortType.BY_ID -> list.sortedBy { it.id } + private fun Flow.combineSortType() = + combine(novelSortTypeFlow) { library, sortType -> + library.copy( + novels = when (sortType) { + NovelSortType.BY_TITLE -> library.novels.mapValues { it.value.sortedBy { it.title } } + NovelSortType.BY_UNREAD_COUNT -> library.novels.mapValues { it.value.sortedBy { it.unread } } + NovelSortType.BY_ID -> library.novels.mapValues { it.value.sortedBy { it.id } } } - } + ) } - private fun Flow>.combineUnreadStatus() = + private fun Flow.combineUnreadStatus() = combine(unreadStatusFlow) { novelResult, sortType -> novelResult.let { list -> sortType?.let { when (sortType) { - INCLUDE -> list.filter { it.unread > 0 } - EXCLUDE -> list.filterNot { it.unread > 0 } + INCLUDE -> list.copy( + novels = list.novels.mapValues { + it.value.filter { it.unread > 0 } + } + ) + EXCLUDE -> list.copy( + novels = list.novels.mapValues { + it.value.filterNot { it.unread > 0 } + } + ) } } ?: list } @@ -361,13 +411,17 @@ class LibraryViewModel( override fun isOnline(): Boolean = isOnlineUseCase() - override fun startUpdateManager() { - startUpdateWorkerUseCase(true) + override fun startUpdateManager(categoryID: Int) { + startUpdateWorkerUseCase(categoryID, true) } override fun removeSelectedFromLibrary() { launchIO { - val selected = liveData.first().filter { it.isSelected } + val selected = liveData.first().novels + .flatMap { it.value } + .distinctBy { it.id } + .filter { it.isSelected } + clearSelected() updateBookmarkedNovelUseCase(selected.map { it.copy(bookmarked = false) @@ -376,7 +430,12 @@ class LibraryViewModel( } override fun getSelectedIds(): Flow = flow { - val ints = selectedNovels.first().keys.toIntArray() + val ints = selectedNovels.first() + .flatMap { (_, map) -> + map.entries.filter { it.value } + .map { it.key } + } + .toIntArray() if (ints.isEmpty()) return@flow clearSelected() emit(ints) @@ -471,6 +530,13 @@ class LibraryViewModel( launchIO { setNovelUITypeUseCase(cardType) } } + override fun setCategories(categories: IntArray) { + launchIO { + val selected = getSelectedIds().first() + setNovelsCategoriesUseCase(selected, categories) + } + } + override fun cycleUnreadFilter(currentState: ToggleableState) { unreadStatusFlow.value = currentState.toInclusionState().cycle() } @@ -501,4 +567,12 @@ class LibraryViewModel( override fun setQuery(s: String) { queryFlow.value = s } + + override val activeCategory: MutableStateFlow by lazy { + MutableStateFlow(0) + } + + override fun setActiveCategory(category: Int) { + activeCategory.value = category + } } \ No newline at end of file diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/NovelViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/NovelViewModel.kt index df4f0625cd..1c4a89753e 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/impl/NovelViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/NovelViewModel.kt @@ -13,6 +13,7 @@ import app.shosetsu.android.common.utils.share.toURL import app.shosetsu.android.domain.repository.base.IChaptersRepository import app.shosetsu.android.domain.usecases.DownloadChapterPassageUseCase import app.shosetsu.android.domain.usecases.IsOnlineUseCase +import app.shosetsu.android.domain.usecases.SetNovelCategoriesUseCase import app.shosetsu.android.domain.usecases.StartDownloadWorkerAfterUpdateUseCase import app.shosetsu.android.domain.usecases.delete.DeleteChapterPassageUseCase import app.shosetsu.android.domain.usecases.delete.TrueDeleteChapterUseCase @@ -24,6 +25,7 @@ import app.shosetsu.android.domain.usecases.update.UpdateNovelSettingUseCase import app.shosetsu.android.domain.usecases.update.UpdateNovelUseCase import app.shosetsu.android.view.AndroidQRCodeDrawable import app.shosetsu.android.view.uimodels.NovelSettingUI +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.view.uimodels.model.ChapterUI import app.shosetsu.android.view.uimodels.model.NovelUI import app.shosetsu.android.viewmodel.abstracted.ANovelViewModel @@ -36,6 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.plus +import kotlin.collections.set /* * This file is part of shosetsu. @@ -81,7 +84,10 @@ class NovelViewModel( private val getTrueDelete: GetTrueDeleteChapterUseCase, private val trueDeleteChapter: TrueDeleteChapterUseCase, private val getInstalledExtensionUseCase: GetInstalledExtensionUseCase, - private val getRepositoryUseCase: GetRepositoryUseCase + private val getRepositoryUseCase: GetRepositoryUseCase, + private val getCategoriesUseCase: GetCategoriesUseCase, + private val getNovelCategoriesUseCase: GetNovelCategoriesUseCase, + private val setNovelCategoriesUseCase: SetNovelCategoriesUseCase ) : ANovelViewModel() { override val chaptersException: MutableStateFlow = MutableStateFlow(null) @@ -147,6 +153,16 @@ class NovelViewModel( novelSettingsFlow.onIO() } + override val categories: Flow> by lazy { + getCategoriesUseCase() + } + + override val novelCategories: Flow> by lazy { + novelIDLive.transformLatest { id: Int -> + emitAll(getNovelCategoriesUseCase(id)) + } + } + override fun getIfAllowTrueDelete(): Flow = flow { emit(getTrueDelete()) @@ -442,6 +458,15 @@ class NovelViewModel( novelIDLive.value = novelID } + override fun setNovelCategories(categories: IntArray): Flow = flow { + val novel = novelFlow.first { it != null }!! + if (!novel.bookmarked) { + toggleNovelBookmark().collect() + } + setNovelCategoriesUseCase(novel.id, categories) + emit(Unit) + } + override fun toggleNovelBookmark(): Flow { return flow { val novel = novelFlow.first { it != null }!! diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/UpdatesViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/UpdatesViewModel.kt index 6d9dc6ffe3..56733906df 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/impl/UpdatesViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/UpdatesViewModel.kt @@ -59,7 +59,7 @@ class UpdatesViewModel( }.shareIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, 1) } - override fun startUpdateManager() = startUpdateWorkerUseCase() + override fun startUpdateManager(categoryID: Int) = startUpdateWorkerUseCase(categoryID) override fun isOnline(): Boolean = isOnlineUseCase() diff --git a/android/src/main/java/app/shosetsu/android/viewmodel/impl/settings/UpdateSettingsViewModel.kt b/android/src/main/java/app/shosetsu/android/viewmodel/impl/settings/UpdateSettingsViewModel.kt index 2d4dca57cb..54db498bbb 100644 --- a/android/src/main/java/app/shosetsu/android/viewmodel/impl/settings/UpdateSettingsViewModel.kt +++ b/android/src/main/java/app/shosetsu/android/viewmodel/impl/settings/UpdateSettingsViewModel.kt @@ -8,8 +8,12 @@ import app.shosetsu.android.common.SettingKey.* import app.shosetsu.android.common.ext.launchIO import app.shosetsu.android.common.ext.logI import app.shosetsu.android.domain.repository.base.ISettingsRepository +import app.shosetsu.android.domain.usecases.get.GetCategoriesUseCase +import app.shosetsu.android.view.uimodels.model.CategoryUI import app.shosetsu.android.viewmodel.abstracted.settings.AUpdateSettingsViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest /* * This file is part of shosetsu. @@ -37,6 +41,7 @@ class UpdateSettingsViewModel( private val novelUpdateCycleManager: NovelUpdateCycleWorker.Manager, private val novelUpdateManager: NovelUpdateWorker.Manager, private val repoUpdateManager: RepositoryUpdateWorker.Manager, + private val getCategoriesUseCase: GetCategoriesUseCase, ) : AUpdateSettingsViewModel(iSettingsRepository) { private fun restartNovelUpdater() { launchIO { @@ -57,6 +62,12 @@ class UpdateSettingsViewModel( repoUpdateManager.start() } + override val categories: Flow> by lazy { + getCategoriesUseCase().mapLatest { + listOf(CategoryUI.default()) + it + } + } + init { launchIO { var firstRun = true diff --git a/android/src/main/res/drawable/ic_baseline_label_24.xml b/android/src/main/res/drawable/ic_baseline_label_24.xml new file mode 100644 index 0000000000..9866861296 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_label_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/layout/categories_add.xml b/android/src/main/res/layout/categories_add.xml new file mode 100644 index 0000000000..fd4d4a026b --- /dev/null +++ b/android/src/main/res/layout/categories_add.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/android/src/main/res/menu/toolbar_library_selected.xml b/android/src/main/res/menu/toolbar_library_selected.xml index 88fa714c0e..7d5aa70293 100644 --- a/android/src/main/res/menu/toolbar_library_selected.xml +++ b/android/src/main/res/menu/toolbar_library_selected.xml @@ -12,6 +12,11 @@ android:title="@string/migrate_sources" app:showAsAction="never" /> + + + + \ No newline at end of file diff --git a/android/src/main/res/navigation/nav_graph.xml b/android/src/main/res/navigation/nav_graph.xml index b2f3b51bdb..dc26a9712e 100644 --- a/android/src/main/res/navigation/nav_graph.xml +++ b/android/src/main/res/navigation/nav_graph.xml @@ -228,6 +228,9 @@ + + \ No newline at end of file diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 09964f2505..14a225ad8e 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -386,6 +386,20 @@ Repository removed. Add + Categories + Set categories + Category name + Add Category + Category Added + Failed to add category + Failed to remove category! + Failed to move category + Category removed. + Add + Category Removal Warning + Are you sure about removing this category? + Failed to undo repository removal + Start Center @@ -474,6 +488,7 @@ Notify extension downloading Create a notification displaying progress when an extension is being downloaded/installed. Restore novel settings + Restore novel categories Error: Received empty result while reloading Cannot load novel content while offline You have read all the chapters. @@ -533,6 +548,11 @@ If toggled on, You will have no idea what novels are being updated Classic notification completion Instead of showing you which how many chapters are in each novel, simply says \"Completed Update\" + Categories to update + Categories to download + Open + Include: %s + Exclude: %s Allow updating on metered connection Update on low battery Update on low storage