Use Compose for Category screen (#7454)
* Use Compose for Category screen * Use correct string for CategoryRenameDialog title Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> (cherry picked from commit 86bacbe586bfe5567b1d52eb8d7b7f23724a17d5) # Conflicts: # app/src/main/java/eu/kanade/data/category/CategoryRepositoryImpl.kt # app/src/main/java/eu/kanade/domain/category/interactor/InsertCategory.kt # app/src/main/java/eu/kanade/domain/category/repository/CategoryRepository.kt # app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt # app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt # app/src/main/res/layout/categories_item.xml
This commit is contained in:
parent
6651ba7211
commit
4e29fd5b2a
@ -5,7 +5,7 @@ import eu.kanade.data.listOfLongsAdapter
|
|||||||
import eu.kanade.domain.category.model.Category
|
import eu.kanade.domain.category.model.Category
|
||||||
import eu.kanade.domain.category.model.CategoryUpdate
|
import eu.kanade.domain.category.model.CategoryUpdate
|
||||||
import eu.kanade.domain.category.repository.CategoryRepository
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
import eu.kanade.domain.category.repository.DuplicateNameException
|
import eu.kanade.tachiyomi.Database
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
class CategoryRepositoryImpl(
|
class CategoryRepositoryImpl(
|
||||||
@ -33,16 +33,14 @@ class CategoryRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
@Throws(DuplicateNameException::class)
|
override suspend fun insert(category: Category): Long {
|
||||||
override suspend fun insert(name: String, order: Long): Long {
|
|
||||||
if (checkDuplicateName(name)) throw DuplicateNameException(name)
|
|
||||||
return handler.awaitOne(true) {
|
return handler.awaitOne(true) {
|
||||||
categoriesQueries.insert(
|
categoriesQueries.insert(
|
||||||
name = name,
|
name = category.name,
|
||||||
order = order,
|
order = category.order,
|
||||||
flags = 0L,
|
flags = category.flags,
|
||||||
// SY -->
|
// SY -->
|
||||||
mangaOrder = emptyList(),
|
mangaOrder = category.mangaOrder,
|
||||||
// SY <--
|
// SY <--
|
||||||
)
|
)
|
||||||
categoriesQueries.selectLastInsertedRowId()
|
categoriesQueries.selectLastInsertedRowId()
|
||||||
@ -50,22 +48,32 @@ class CategoryRepositoryImpl(
|
|||||||
}
|
}
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
@Throws(DuplicateNameException::class)
|
override suspend fun updatePartial(update: CategoryUpdate) {
|
||||||
override suspend fun update(payload: CategoryUpdate) {
|
|
||||||
if (payload.name != null && checkDuplicateName(payload.name)) throw DuplicateNameException(payload.name)
|
|
||||||
handler.await {
|
handler.await {
|
||||||
categoriesQueries.update(
|
updatePartialBlocking(update)
|
||||||
name = payload.name,
|
|
||||||
order = payload.order,
|
|
||||||
flags = payload.flags,
|
|
||||||
categoryId = payload.id,
|
|
||||||
// SY -->
|
|
||||||
mangaOrder = payload.mangaOrder?.let(listOfLongsAdapter::encode),
|
|
||||||
// SY <--
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updatePartial(updates: List<CategoryUpdate>) {
|
||||||
|
handler.await(true) {
|
||||||
|
for (update in updates) {
|
||||||
|
updatePartialBlocking(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Database.updatePartialBlocking(update: CategoryUpdate) {
|
||||||
|
categoriesQueries.update(
|
||||||
|
name = update.name,
|
||||||
|
order = update.order,
|
||||||
|
flags = update.flags,
|
||||||
|
categoryId = update.id,
|
||||||
|
// SY -->
|
||||||
|
mangaOrder = update.mangaOrder?.let(listOfLongsAdapter::encode),
|
||||||
|
// SY <--
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun delete(categoryId: Long) {
|
override suspend fun delete(categoryId: Long) {
|
||||||
handler.await {
|
handler.await {
|
||||||
categoriesQueries.delete(
|
categoriesQueries.delete(
|
||||||
@ -73,10 +81,4 @@ class CategoryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun checkDuplicateName(name: String): Boolean {
|
|
||||||
return handler
|
|
||||||
.awaitList { categoriesQueries.getCategories() }
|
|
||||||
.any { it.name == name }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,11 @@ import eu.kanade.data.history.HistoryRepositoryImpl
|
|||||||
import eu.kanade.data.manga.MangaRepositoryImpl
|
import eu.kanade.data.manga.MangaRepositoryImpl
|
||||||
import eu.kanade.data.source.SourceRepositoryImpl
|
import eu.kanade.data.source.SourceRepositoryImpl
|
||||||
import eu.kanade.data.track.TrackRepositoryImpl
|
import eu.kanade.data.track.TrackRepositoryImpl
|
||||||
|
import eu.kanade.domain.category.interactor.CreateCategoryWithName
|
||||||
import eu.kanade.domain.category.interactor.DeleteCategory
|
import eu.kanade.domain.category.interactor.DeleteCategory
|
||||||
import eu.kanade.domain.category.interactor.GetCategories
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
import eu.kanade.domain.category.interactor.InsertCategory
|
import eu.kanade.domain.category.interactor.RenameCategory
|
||||||
|
import eu.kanade.domain.category.interactor.ReorderCategory
|
||||||
import eu.kanade.domain.category.interactor.SetMangaCategories
|
import eu.kanade.domain.category.interactor.SetMangaCategories
|
||||||
import eu.kanade.domain.category.interactor.UpdateCategory
|
import eu.kanade.domain.category.interactor.UpdateCategory
|
||||||
import eu.kanade.domain.category.repository.CategoryRepository
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
@ -69,7 +71,9 @@ class DomainModule : InjektModule {
|
|||||||
override fun InjektRegistrar.registerInjectables() {
|
override fun InjektRegistrar.registerInjectables() {
|
||||||
addSingletonFactory<CategoryRepository> { CategoryRepositoryImpl(get()) }
|
addSingletonFactory<CategoryRepository> { CategoryRepositoryImpl(get()) }
|
||||||
addFactory { GetCategories(get()) }
|
addFactory { GetCategories(get()) }
|
||||||
addFactory { InsertCategory(get()) }
|
addFactory { CreateCategoryWithName(get()) }
|
||||||
|
addFactory { RenameCategory(get()) }
|
||||||
|
addFactory { ReorderCategory(get()) }
|
||||||
addFactory { UpdateCategory(get()) }
|
addFactory { UpdateCategory(get()) }
|
||||||
addFactory { DeleteCategory(get()) }
|
addFactory { DeleteCategory(get()) }
|
||||||
|
|
||||||
|
@ -9,10 +9,12 @@ import eu.kanade.domain.chapter.interactor.DeleteChapters
|
|||||||
import eu.kanade.domain.chapter.interactor.GetChapterByUrl
|
import eu.kanade.domain.chapter.interactor.GetChapterByUrl
|
||||||
import eu.kanade.domain.chapter.interactor.GetMergedChapterByMangaId
|
import eu.kanade.domain.chapter.interactor.GetMergedChapterByMangaId
|
||||||
import eu.kanade.domain.history.interactor.GetHistoryByMangaId
|
import eu.kanade.domain.history.interactor.GetHistoryByMangaId
|
||||||
|
import eu.kanade.domain.manga.interactor.CreateSortTag
|
||||||
import eu.kanade.domain.manga.interactor.DeleteByMergeId
|
import eu.kanade.domain.manga.interactor.DeleteByMergeId
|
||||||
import eu.kanade.domain.manga.interactor.DeleteFavoriteEntries
|
import eu.kanade.domain.manga.interactor.DeleteFavoriteEntries
|
||||||
import eu.kanade.domain.manga.interactor.DeleteMangaById
|
import eu.kanade.domain.manga.interactor.DeleteMangaById
|
||||||
import eu.kanade.domain.manga.interactor.DeleteMergeById
|
import eu.kanade.domain.manga.interactor.DeleteMergeById
|
||||||
|
import eu.kanade.domain.manga.interactor.DeleteSortTag
|
||||||
import eu.kanade.domain.manga.interactor.GetAllManga
|
import eu.kanade.domain.manga.interactor.GetAllManga
|
||||||
import eu.kanade.domain.manga.interactor.GetExhFavoriteMangaWithMetadata
|
import eu.kanade.domain.manga.interactor.GetExhFavoriteMangaWithMetadata
|
||||||
import eu.kanade.domain.manga.interactor.GetFavoriteEntries
|
import eu.kanade.domain.manga.interactor.GetFavoriteEntries
|
||||||
@ -26,9 +28,11 @@ import eu.kanade.domain.manga.interactor.GetMergedReferencesById
|
|||||||
import eu.kanade.domain.manga.interactor.GetSearchMetadata
|
import eu.kanade.domain.manga.interactor.GetSearchMetadata
|
||||||
import eu.kanade.domain.manga.interactor.GetSearchTags
|
import eu.kanade.domain.manga.interactor.GetSearchTags
|
||||||
import eu.kanade.domain.manga.interactor.GetSearchTitles
|
import eu.kanade.domain.manga.interactor.GetSearchTitles
|
||||||
|
import eu.kanade.domain.manga.interactor.GetSortTag
|
||||||
import eu.kanade.domain.manga.interactor.InsertFavoriteEntries
|
import eu.kanade.domain.manga.interactor.InsertFavoriteEntries
|
||||||
import eu.kanade.domain.manga.interactor.InsertFlatMetadata
|
import eu.kanade.domain.manga.interactor.InsertFlatMetadata
|
||||||
import eu.kanade.domain.manga.interactor.InsertMergedReference
|
import eu.kanade.domain.manga.interactor.InsertMergedReference
|
||||||
|
import eu.kanade.domain.manga.interactor.ReorderSortTag
|
||||||
import eu.kanade.domain.manga.interactor.SetMangaFilteredScanlators
|
import eu.kanade.domain.manga.interactor.SetMangaFilteredScanlators
|
||||||
import eu.kanade.domain.manga.interactor.UpdateMergedSettings
|
import eu.kanade.domain.manga.interactor.UpdateMergedSettings
|
||||||
import eu.kanade.domain.manga.repository.FavoritesEntryRepository
|
import eu.kanade.domain.manga.repository.FavoritesEntryRepository
|
||||||
@ -36,8 +40,12 @@ import eu.kanade.domain.manga.repository.MangaMergeRepository
|
|||||||
import eu.kanade.domain.manga.repository.MangaMetadataRepository
|
import eu.kanade.domain.manga.repository.MangaMetadataRepository
|
||||||
import eu.kanade.domain.source.interactor.CountFeedSavedSearchBySourceId
|
import eu.kanade.domain.source.interactor.CountFeedSavedSearchBySourceId
|
||||||
import eu.kanade.domain.source.interactor.CountFeedSavedSearchGlobal
|
import eu.kanade.domain.source.interactor.CountFeedSavedSearchGlobal
|
||||||
|
import eu.kanade.domain.source.interactor.CreateSourceCategory
|
||||||
|
import eu.kanade.domain.source.interactor.CreateSourceRepo
|
||||||
import eu.kanade.domain.source.interactor.DeleteFeedSavedSearchById
|
import eu.kanade.domain.source.interactor.DeleteFeedSavedSearchById
|
||||||
import eu.kanade.domain.source.interactor.DeleteSavedSearchById
|
import eu.kanade.domain.source.interactor.DeleteSavedSearchById
|
||||||
|
import eu.kanade.domain.source.interactor.DeleteSourceCategory
|
||||||
|
import eu.kanade.domain.source.interactor.DeleteSourceRepos
|
||||||
import eu.kanade.domain.source.interactor.GetExhSavedSearch
|
import eu.kanade.domain.source.interactor.GetExhSavedSearch
|
||||||
import eu.kanade.domain.source.interactor.GetFeedSavedSearchBySourceId
|
import eu.kanade.domain.source.interactor.GetFeedSavedSearchBySourceId
|
||||||
import eu.kanade.domain.source.interactor.GetFeedSavedSearchGlobal
|
import eu.kanade.domain.source.interactor.GetFeedSavedSearchGlobal
|
||||||
@ -47,8 +55,10 @@ import eu.kanade.domain.source.interactor.GetSavedSearchBySourceIdFeed
|
|||||||
import eu.kanade.domain.source.interactor.GetSavedSearchGlobalFeed
|
import eu.kanade.domain.source.interactor.GetSavedSearchGlobalFeed
|
||||||
import eu.kanade.domain.source.interactor.GetShowLatest
|
import eu.kanade.domain.source.interactor.GetShowLatest
|
||||||
import eu.kanade.domain.source.interactor.GetSourceCategories
|
import eu.kanade.domain.source.interactor.GetSourceCategories
|
||||||
|
import eu.kanade.domain.source.interactor.GetSourceRepos
|
||||||
import eu.kanade.domain.source.interactor.InsertFeedSavedSearch
|
import eu.kanade.domain.source.interactor.InsertFeedSavedSearch
|
||||||
import eu.kanade.domain.source.interactor.InsertSavedSearch
|
import eu.kanade.domain.source.interactor.InsertSavedSearch
|
||||||
|
import eu.kanade.domain.source.interactor.RenameSourceCategory
|
||||||
import eu.kanade.domain.source.interactor.SetSourceCategories
|
import eu.kanade.domain.source.interactor.SetSourceCategories
|
||||||
import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
|
import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
|
||||||
import eu.kanade.domain.source.interactor.ToggleSources
|
import eu.kanade.domain.source.interactor.ToggleSources
|
||||||
@ -64,7 +74,6 @@ import xyz.nulldev.ts.api.http.serializer.FilterSerializer
|
|||||||
class SYDomainModule : InjektModule {
|
class SYDomainModule : InjektModule {
|
||||||
|
|
||||||
override fun InjektRegistrar.registerInjectables() {
|
override fun InjektRegistrar.registerInjectables() {
|
||||||
addFactory { GetSourceCategories(get()) }
|
|
||||||
addFactory { GetShowLatest(get()) }
|
addFactory { GetShowLatest(get()) }
|
||||||
addFactory { ToggleExcludeFromDataSaver(get()) }
|
addFactory { ToggleExcludeFromDataSaver(get()) }
|
||||||
addFactory { SetSourceCategories(get()) }
|
addFactory { SetSourceCategories(get()) }
|
||||||
@ -77,6 +86,17 @@ class SYDomainModule : InjektModule {
|
|||||||
addFactory { FilterSerializer() }
|
addFactory { FilterSerializer() }
|
||||||
addFactory { GetHistoryByMangaId(get()) }
|
addFactory { GetHistoryByMangaId(get()) }
|
||||||
addFactory { GetChapterByUrl(get()) }
|
addFactory { GetChapterByUrl(get()) }
|
||||||
|
addFactory { CreateSourceRepo(get()) }
|
||||||
|
addFactory { DeleteSourceRepos(get()) }
|
||||||
|
addFactory { GetSourceRepos(get()) }
|
||||||
|
addFactory { GetSourceCategories(get()) }
|
||||||
|
addFactory { CreateSourceCategory(get()) }
|
||||||
|
addFactory { RenameSourceCategory(get(), get()) }
|
||||||
|
addFactory { DeleteSourceCategory(get()) }
|
||||||
|
addFactory { GetSortTag(get()) }
|
||||||
|
addFactory { CreateSortTag(get(), get()) }
|
||||||
|
addFactory { DeleteSortTag(get(), get()) }
|
||||||
|
addFactory { ReorderSortTag(get(), get()) }
|
||||||
|
|
||||||
addSingletonFactory<MangaMetadataRepository> { MangaMetadataRepositoryImpl(get()) }
|
addSingletonFactory<MangaMetadataRepository> { MangaMetadataRepositoryImpl(get()) }
|
||||||
addFactory { GetFlatMetadataById(get()) }
|
addFactory { GetFlatMetadataById(get()) }
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
package eu.kanade.domain.category.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.domain.category.model.anyWithName
|
||||||
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import logcat.LogPriority
|
||||||
|
|
||||||
|
class CreateCategoryWithName(
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun await(name: String): Result = withContext(NonCancellable) await@{
|
||||||
|
val categories = categoryRepository.getAll()
|
||||||
|
if (categories.anyWithName(name)) {
|
||||||
|
return@await Result.NameAlreadyExistsError
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
||||||
|
val newCategory = Category(
|
||||||
|
id = 0,
|
||||||
|
name = name,
|
||||||
|
order = nextOrder,
|
||||||
|
flags = 0,
|
||||||
|
mangaOrder = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
categoryRepository.insert(newCategory)
|
||||||
|
Result.Success(/* SY --> */newCategory/* SY <-- */)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
Result.InternalError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
// SY -->
|
||||||
|
data class Success(val category: Category) : Result()
|
||||||
|
|
||||||
|
// SY <--
|
||||||
|
object NameAlreadyExistsError : Result()
|
||||||
|
data class InternalError(val error: Throwable) : Result()
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,43 @@
|
|||||||
package eu.kanade.domain.category.interactor
|
package eu.kanade.domain.category.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.category.model.CategoryUpdate
|
||||||
import eu.kanade.domain.category.repository.CategoryRepository
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import logcat.LogPriority
|
||||||
|
|
||||||
class DeleteCategory(
|
class DeleteCategory(
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun await(categoryId: Long) {
|
suspend fun await(categoryId: Long) = withContext(NonCancellable) await@{
|
||||||
categoryRepository.delete(categoryId)
|
try {
|
||||||
|
categoryRepository.delete(categoryId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
return@await Result.InternalError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val categories = categoryRepository.getAll()
|
||||||
|
val updates = categories.mapIndexed { index, category ->
|
||||||
|
CategoryUpdate(
|
||||||
|
id = category.id,
|
||||||
|
order = index.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
categoryRepository.updatePartial(updates)
|
||||||
|
Result.Success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
Result.InternalError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object Success : Result()
|
||||||
|
data class InternalError(val error: Throwable) : Result()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
package eu.kanade.domain.category.interactor
|
|
||||||
|
|
||||||
import eu.kanade.domain.category.repository.CategoryRepository
|
|
||||||
|
|
||||||
class InsertCategory(
|
|
||||||
private val categoryRepository: CategoryRepository,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun await(name: String, order: Long): Result {
|
|
||||||
return try {
|
|
||||||
// SY -->
|
|
||||||
Result.Success(categoryRepository.insert(name, order))
|
|
||||||
// SY <--
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.Error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class Result {
|
|
||||||
// SY -->
|
|
||||||
data class Success(val id: Long) : Result()
|
|
||||||
|
|
||||||
// Sy <--
|
|
||||||
data class Error(val error: Exception) : Result()
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,43 @@
|
|||||||
|
package eu.kanade.domain.category.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.domain.category.model.CategoryUpdate
|
||||||
|
import eu.kanade.domain.category.model.anyWithName
|
||||||
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import logcat.LogPriority
|
||||||
|
|
||||||
|
class RenameCategory(
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun await(categoryId: Long, name: String) = withContext(NonCancellable) await@{
|
||||||
|
val categories = categoryRepository.getAll()
|
||||||
|
if (categories.anyWithName(name)) {
|
||||||
|
return@await Result.NameAlreadyExistsError
|
||||||
|
}
|
||||||
|
|
||||||
|
val update = CategoryUpdate(
|
||||||
|
id = categoryId,
|
||||||
|
name = name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
categoryRepository.updatePartial(update)
|
||||||
|
Result.Success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
Result.InternalError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun await(category: Category, name: String) = await(category.id, name)
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object Success : Result()
|
||||||
|
object NameAlreadyExistsError : Result()
|
||||||
|
data class InternalError(val error: Throwable) : Result()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
package eu.kanade.domain.category.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.domain.category.model.CategoryUpdate
|
||||||
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import logcat.LogPriority
|
||||||
|
|
||||||
|
class ReorderCategory(
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun await(categoryId: Long, newPosition: Int) = withContext(NonCancellable) await@{
|
||||||
|
val categories = categoryRepository.getAll()
|
||||||
|
|
||||||
|
val currentIndex = categories.indexOfFirst { it.id == categoryId }
|
||||||
|
if (currentIndex == newPosition) {
|
||||||
|
return@await Result.Unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
val reorderedCategories = categories.toMutableList()
|
||||||
|
val reorderedCategory = reorderedCategories.removeAt(currentIndex)
|
||||||
|
reorderedCategories.add(newPosition, reorderedCategory)
|
||||||
|
|
||||||
|
val updates = reorderedCategories.mapIndexed { index, category ->
|
||||||
|
CategoryUpdate(
|
||||||
|
id = category.id,
|
||||||
|
order = index.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
categoryRepository.updatePartial(updates)
|
||||||
|
Result.Success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
Result.InternalError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun await(category: Category, newPosition: Long): Result =
|
||||||
|
await(category.id, newPosition.toInt())
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object Success : Result()
|
||||||
|
object Unchanged : Result()
|
||||||
|
data class InternalError(val error: Throwable) : Result()
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,16 @@ package eu.kanade.domain.category.interactor
|
|||||||
|
|
||||||
import eu.kanade.domain.category.model.CategoryUpdate
|
import eu.kanade.domain.category.model.CategoryUpdate
|
||||||
import eu.kanade.domain.category.repository.CategoryRepository
|
import eu.kanade.domain.category.repository.CategoryRepository
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class UpdateCategory(
|
class UpdateCategory(
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun await(payload: CategoryUpdate): Result {
|
suspend fun await(payload: CategoryUpdate): Result = withContext(NonCancellable) {
|
||||||
return try {
|
try {
|
||||||
categoryRepository.update(payload)
|
categoryRepository.updatePartial(payload)
|
||||||
Result.Success
|
Result.Success
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.Error(e)
|
Result.Error(e)
|
||||||
|
@ -41,6 +41,10 @@ data class Category(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun List<Category>.anyWithName(name: String): Boolean {
|
||||||
|
return any { name.equals(it.name, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
fun Category.toDbCategory(): DbCategory = CategoryImpl().also {
|
fun Category.toDbCategory(): DbCategory = CategoryImpl().also {
|
||||||
it.name = name
|
it.name = name
|
||||||
it.id = id.toInt()
|
it.id = id.toInt()
|
||||||
|
@ -15,16 +15,12 @@ interface CategoryRepository {
|
|||||||
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
|
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
@Throws(DuplicateNameException::class)
|
suspend fun insert(category: Category): Long
|
||||||
suspend fun insert(name: String, order: Long): Long
|
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
@Throws(DuplicateNameException::class)
|
suspend fun updatePartial(update: CategoryUpdate)
|
||||||
suspend fun update(payload: CategoryUpdate)
|
|
||||||
|
suspend fun updatePartial(updates: List<CategoryUpdate>)
|
||||||
|
|
||||||
suspend fun delete(categoryId: Long)
|
suspend fun delete(categoryId: Long)
|
||||||
|
|
||||||
suspend fun checkDuplicateName(name: String): Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DuplicateNameException(name: String) : Exception("There's a category which is named \"$name\" already")
|
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.kanade.domain.manga.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||||
|
|
||||||
|
class CreateSortTag(
|
||||||
|
private val preferences: PreferencesHelper,
|
||||||
|
private val getSortTag: GetSortTag,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun await(tag: String): Result {
|
||||||
|
// Do not allow duplicate categories.
|
||||||
|
// Do not allow duplicate categories.
|
||||||
|
if (tagExists(tag.trim())) {
|
||||||
|
return Result.TagExists
|
||||||
|
}
|
||||||
|
|
||||||
|
val size = preferences.sortTagsForLibrary().get().size
|
||||||
|
|
||||||
|
preferences.sortTagsForLibrary() += encodeTag(size, tag)
|
||||||
|
|
||||||
|
return Result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object TagExists : Result()
|
||||||
|
object Success : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a tag with the given name already exists.
|
||||||
|
*/
|
||||||
|
private fun tagExists(name: String): Boolean {
|
||||||
|
return getSortTag.await().any { it.equals(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun encodeTag(index: Int, tag: String) = "$index|${tag.trim()}"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package eu.kanade.domain.manga.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.preference.minusAssign
|
||||||
|
|
||||||
|
class DeleteSortTag(
|
||||||
|
private val preferences: PreferencesHelper,
|
||||||
|
private val getSortTag: GetSortTag,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun await(tag: String) {
|
||||||
|
getSortTag.await().withIndex().find { it.value == tag }?.let {
|
||||||
|
preferences.sortTagsForLibrary() -= CreateSortTag.encodeTag(it.index, it.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package eu.kanade.domain.manga.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class GetSortTag(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun subscribe(): Flow<List<String>> {
|
||||||
|
return preferences.sortTagsForLibrary().asFlow()
|
||||||
|
.map(::mapSortTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun await() = getSortTags(preferences).let(::mapSortTags)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getSortTags(preferences: PreferencesHelper) = preferences.sortTagsForLibrary().get()
|
||||||
|
|
||||||
|
fun mapSortTags(tags: Set<String>) = tags.mapNotNull {
|
||||||
|
val index = it.indexOf('|')
|
||||||
|
if (index != -1) {
|
||||||
|
(it.substring(0, index).toIntOrNull() ?: return@mapNotNull null) to it.substring(index + 1)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
.sortedBy { it.first }.map { it.second }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.kanade.domain.manga.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
|
||||||
|
class ReorderSortTag(
|
||||||
|
private val preferences: PreferencesHelper,
|
||||||
|
private val getSortTag: GetSortTag,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun await(tag: String, newPosition: Int): Result {
|
||||||
|
val tags = getSortTag.await()
|
||||||
|
val currentIndex = tags.indexOfFirst { it == tag }
|
||||||
|
|
||||||
|
if (currentIndex == -1) {
|
||||||
|
return Result.InternalError
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex == newPosition) {
|
||||||
|
return Result.Unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
val reorderedTags = tags.toMutableList()
|
||||||
|
val reorderedTag = reorderedTags.removeAt(currentIndex)
|
||||||
|
reorderedTags.add(newPosition, reorderedTag)
|
||||||
|
|
||||||
|
preferences.sortTagsForLibrary().set(
|
||||||
|
reorderedTags.mapIndexed { index, s ->
|
||||||
|
CreateSortTag.encodeTag(index, s)
|
||||||
|
}.toSet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object Success : Result()
|
||||||
|
object Unchanged : Result()
|
||||||
|
object InternalError : Result()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||||
|
|
||||||
|
class CreateSourceCategory(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun await(category: String): Result {
|
||||||
|
// Do not allow duplicate categories.
|
||||||
|
if (categoryExists(category)) {
|
||||||
|
return Result.CategoryExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.contains("|")) {
|
||||||
|
return Result.InvalidName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create category.
|
||||||
|
preferences.sourcesTabCategories() += category
|
||||||
|
|
||||||
|
return Result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object CategoryExists : Result()
|
||||||
|
object InvalidName : Result()
|
||||||
|
object Success : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a repo with the given name already exists.
|
||||||
|
*/
|
||||||
|
private fun categoryExists(name: String): Boolean {
|
||||||
|
return preferences.sourcesTabCategories().get().any { it.equals(name, true) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||||
|
|
||||||
|
class CreateSourceRepo(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun await(name: String): Result {
|
||||||
|
// Do not allow duplicate repos.
|
||||||
|
if (repoExists(name)) {
|
||||||
|
return Result.RepoExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow invalid formats
|
||||||
|
if (!name.matches(repoRegex)) {
|
||||||
|
return Result.InvalidName
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.extensionRepos() += name
|
||||||
|
|
||||||
|
return Result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
object RepoExists : Result()
|
||||||
|
object InvalidName : Result()
|
||||||
|
object Success : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a repo with the given name already exists.
|
||||||
|
*/
|
||||||
|
private fun repoExists(name: String): Boolean {
|
||||||
|
return preferences.extensionRepos().get().any { it.equals(name, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.preference.minusAssign
|
||||||
|
|
||||||
|
class DeleteSourceCategory(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun await(category: String) {
|
||||||
|
preferences.sourcesTabSourcesInCategories().set(
|
||||||
|
preferences.sourcesTabSourcesInCategories().get()
|
||||||
|
.filterNot { it.substringAfter("|") == category }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
preferences.sourcesTabCategories() -= category
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
|
||||||
|
class DeleteSourceRepos(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun await(repos: List<String>) {
|
||||||
|
preferences.extensionRepos().set(
|
||||||
|
preferences.extensionRepos().get().filterNot { it in repos }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -2,12 +2,13 @@ package eu.kanade.domain.source.interactor
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class GetSourceCategories(
|
class GetSourceCategories(
|
||||||
private val preferences: PreferencesHelper,
|
private val preferences: PreferencesHelper,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun subscribe(): Flow<Set<String>> {
|
fun subscribe(): Flow<List<String>> {
|
||||||
return preferences.sourcesTabCategories().asFlow()
|
return preferences.sourcesTabCategories().asFlow().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class GetSourceRepos(private val preferences: PreferencesHelper) {
|
||||||
|
|
||||||
|
fun subscribe(): Flow<List<String>> {
|
||||||
|
return preferences.extensionRepos().asFlow().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
|
||||||
|
class RenameSourceCategory(
|
||||||
|
private val preferences: PreferencesHelper,
|
||||||
|
private val createSourceCategory: CreateSourceCategory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun await(categoryOld: String, categoryNew: String): CreateSourceCategory.Result {
|
||||||
|
when (val result = createSourceCategory.await(categoryNew)) {
|
||||||
|
CreateSourceCategory.Result.CategoryExists -> return result
|
||||||
|
CreateSourceCategory.Result.InvalidName -> return result
|
||||||
|
CreateSourceCategory.Result.Success -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.sourcesTabSourcesInCategories().set(
|
||||||
|
preferences.sourcesTabSourcesInCategories().get()
|
||||||
|
.map {
|
||||||
|
val index = it.indexOf('|')
|
||||||
|
if (index != -1 && it.substring(index + 1) == categoryOld) {
|
||||||
|
it.substring(0, index + 1) + categoryNew
|
||||||
|
} else it
|
||||||
|
}
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
preferences.sourcesTabCategories().set(
|
||||||
|
preferences.sourcesTabCategories().get()
|
||||||
|
.minus(categoryOld)
|
||||||
|
.plus(categoryNew),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateSourceCategory.Result.Success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
package eu.kanade.presentation.category
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarScrollState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.category.components.CategoryContent
|
||||||
|
import eu.kanade.presentation.category.components.CategoryCreateDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryDeleteDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
|
import eu.kanade.presentation.category.components.CategoryRenameDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryTopAppBar
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
import eu.kanade.presentation.util.plus
|
||||||
|
import eu.kanade.presentation.util.topPaddingValues
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.ui.category.CategoryPresenter
|
||||||
|
import eu.kanade.tachiyomi.ui.category.CategoryPresenter.Dialog
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryScreen(
|
||||||
|
presenter: CategoryPresenter,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val topAppBarScrollState = rememberTopAppBarScrollState()
|
||||||
|
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarScrollState)
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CategoryTopAppBar(
|
||||||
|
topAppBarScrollBehavior = topAppBarScrollBehavior,
|
||||||
|
navigateUp = navigateUp,
|
||||||
|
title = stringResource(id = R.string.action_edit_categories),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CategoryFloatingActionButton(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
onCreate = { presenter.dialog = CategoryPresenter.Dialog.Create },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
val categories by presenter.categories.collectAsState(initial = emptyList())
|
||||||
|
if (categories.isEmpty()) {
|
||||||
|
EmptyScreen(textResource = R.string.information_empty_category)
|
||||||
|
} else {
|
||||||
|
CategoryContent(
|
||||||
|
categories = categories,
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
|
||||||
|
onMoveUp = { presenter.moveUp(it) },
|
||||||
|
onMoveDown = { presenter.moveDown(it) },
|
||||||
|
onRename = { presenter.dialog = Dialog.Rename(it) },
|
||||||
|
onDelete = { presenter.dialog = Dialog.Delete(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val onDismissRequest = { presenter.dialog = null }
|
||||||
|
when (val dialog = presenter.dialog) {
|
||||||
|
Dialog.Create -> {
|
||||||
|
CategoryCreateDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onCreate = { presenter.createCategory(it) },
|
||||||
|
title = stringResource(R.string.action_add_category),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Rename -> {
|
||||||
|
CategoryRenameDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onRename = { presenter.renameCategory(dialog.category, it) },
|
||||||
|
category = dialog.category.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Delete -> {
|
||||||
|
CategoryDeleteDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onDelete = { presenter.deleteCategory(dialog.category) },
|
||||||
|
title = stringResource(R.string.delete_category),
|
||||||
|
text = stringResource(R.string.delete_category_confirmation, dialog.category.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
presenter.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is CategoryPresenter.Event.CategoryWithNameAlreadyExists -> {
|
||||||
|
context.toast(R.string.error_category_exists)
|
||||||
|
}
|
||||||
|
is CategoryPresenter.Event.InternalError -> {
|
||||||
|
context.toast(R.string.internal_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
package eu.kanade.presentation.category
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarScrollState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.category.components.CategoryCreateDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryDeleteDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
|
import eu.kanade.presentation.category.components.CategoryTopAppBar
|
||||||
|
import eu.kanade.presentation.category.components.genre.SortTagContent
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
import eu.kanade.presentation.util.plus
|
||||||
|
import eu.kanade.presentation.util.topPaddingValues
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.ui.category.genre.SortTagPresenter
|
||||||
|
import eu.kanade.tachiyomi.ui.category.genre.SortTagPresenter.Dialog
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SortTagScreen(
|
||||||
|
presenter: SortTagPresenter,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val topAppBarScrollState = rememberTopAppBarScrollState()
|
||||||
|
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarScrollState)
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CategoryTopAppBar(
|
||||||
|
topAppBarScrollBehavior = topAppBarScrollBehavior,
|
||||||
|
navigateUp = navigateUp,
|
||||||
|
title = stringResource(id = R.string.action_edit_tags),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CategoryFloatingActionButton(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
onCreate = { presenter.dialog = Dialog.Create },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
val tags by presenter.tags.collectAsState(initial = emptyList())
|
||||||
|
if (tags.isEmpty()) {
|
||||||
|
EmptyScreen(textResource = R.string.information_empty_tags)
|
||||||
|
} else {
|
||||||
|
SortTagContent(
|
||||||
|
categories = tags,
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
|
||||||
|
onMoveUp = { tag, index -> presenter.moveUp(tag, index) },
|
||||||
|
onMoveDown = { tag, index -> presenter.moveDown(tag, index) },
|
||||||
|
onDelete = { presenter.dialog = Dialog.Delete(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val onDismissRequest = { presenter.dialog = null }
|
||||||
|
when (val dialog = presenter.dialog) {
|
||||||
|
Dialog.Create -> {
|
||||||
|
CategoryCreateDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onCreate = { presenter.createTag(it) },
|
||||||
|
title = stringResource(R.string.add_tag),
|
||||||
|
extraMessage = stringResource(R.string.action_add_tags_message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Delete -> {
|
||||||
|
CategoryDeleteDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onDelete = { presenter.delete(dialog.tag) },
|
||||||
|
title = stringResource(R.string.delete_tag),
|
||||||
|
text = stringResource(R.string.delete_tag_confirmation, dialog.tag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
presenter.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is SortTagPresenter.Event.TagExists -> {
|
||||||
|
context.toast(R.string.error_tag_exists)
|
||||||
|
}
|
||||||
|
is SortTagPresenter.Event.InternalError -> {
|
||||||
|
context.toast(R.string.internal_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
package eu.kanade.presentation.category
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarScrollState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.category.components.CategoryCreateDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryDeleteDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
|
import eu.kanade.presentation.category.components.CategoryRenameDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryTopAppBar
|
||||||
|
import eu.kanade.presentation.category.components.sources.SourceCategoryContent
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
import eu.kanade.presentation.util.plus
|
||||||
|
import eu.kanade.presentation.util.topPaddingValues
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryPresenter
|
||||||
|
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryPresenter.Dialog
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceCategoryScreen(
|
||||||
|
presenter: SourceCategoryPresenter,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val topAppBarScrollState = rememberTopAppBarScrollState()
|
||||||
|
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarScrollState)
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CategoryTopAppBar(
|
||||||
|
topAppBarScrollBehavior = topAppBarScrollBehavior,
|
||||||
|
navigateUp = navigateUp,
|
||||||
|
title = stringResource(id = R.string.action_edit_categories),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CategoryFloatingActionButton(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
onCreate = { presenter.dialog = Dialog.Create },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
val categories by presenter.categories.collectAsState(initial = emptyList())
|
||||||
|
if (categories.isEmpty()) {
|
||||||
|
EmptyScreen(textResource = R.string.information_empty_category)
|
||||||
|
} else {
|
||||||
|
SourceCategoryContent(
|
||||||
|
categories = categories,
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
|
||||||
|
onRename = { presenter.dialog = Dialog.Rename(it) },
|
||||||
|
onDelete = { presenter.dialog = Dialog.Delete(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val onDismissRequest = { presenter.dialog = null }
|
||||||
|
when (val dialog = presenter.dialog) {
|
||||||
|
Dialog.Create -> {
|
||||||
|
CategoryCreateDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onCreate = { presenter.createCategory(it) },
|
||||||
|
title = stringResource(R.string.action_add_category),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Rename -> {
|
||||||
|
CategoryRenameDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onRename = { presenter.renameCategory(dialog.category, it) },
|
||||||
|
category = dialog.category,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Delete -> {
|
||||||
|
CategoryDeleteDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onDelete = { presenter.deleteCategory(dialog.category) },
|
||||||
|
title = stringResource(R.string.delete_category),
|
||||||
|
text = stringResource(R.string.delete_category_confirmation, dialog.category),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
presenter.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is SourceCategoryPresenter.Event.CategoryExists -> {
|
||||||
|
context.toast(R.string.error_category_exists)
|
||||||
|
}
|
||||||
|
is SourceCategoryPresenter.Event.InternalError -> {
|
||||||
|
context.toast(R.string.internal_error)
|
||||||
|
}
|
||||||
|
SourceCategoryPresenter.Event.InvalidName -> {
|
||||||
|
context.toast(R.string.invalid_category_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,106 @@
|
|||||||
|
package eu.kanade.presentation.category
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarScrollState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.category.components.CategoryCreateDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryDeleteDialog
|
||||||
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
|
import eu.kanade.presentation.category.components.CategoryTopAppBar
|
||||||
|
import eu.kanade.presentation.category.components.repo.SourceRepoContent
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
import eu.kanade.presentation.util.plus
|
||||||
|
import eu.kanade.presentation.util.topPaddingValues
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.ui.category.repos.RepoPresenter
|
||||||
|
import eu.kanade.tachiyomi.ui.category.repos.RepoPresenter.Dialog
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceRepoScreen(
|
||||||
|
presenter: RepoPresenter,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val topAppBarScrollState = rememberTopAppBarScrollState()
|
||||||
|
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarScrollState)
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CategoryTopAppBar(
|
||||||
|
topAppBarScrollBehavior = topAppBarScrollBehavior,
|
||||||
|
navigateUp = navigateUp,
|
||||||
|
title = stringResource(R.string.action_edit_repos),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CategoryFloatingActionButton(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
onCreate = { presenter.dialog = Dialog.Create },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
val repos by presenter.repos.collectAsState(initial = emptyList())
|
||||||
|
if (repos.isEmpty()) {
|
||||||
|
EmptyScreen(textResource = R.string.information_empty_repos)
|
||||||
|
} else {
|
||||||
|
SourceRepoContent(
|
||||||
|
repos = repos,
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
|
||||||
|
onDelete = { presenter.dialog = Dialog.Delete(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val onDismissRequest = { presenter.dialog = null }
|
||||||
|
when (val dialog = presenter.dialog) {
|
||||||
|
Dialog.Create -> {
|
||||||
|
CategoryCreateDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onCreate = { presenter.createRepo(it) },
|
||||||
|
title = stringResource(R.string.action_add_repo),
|
||||||
|
extraMessage = stringResource(R.string.action_add_repo_message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Dialog.Delete -> {
|
||||||
|
CategoryDeleteDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onDelete = { presenter.deleteRepos(listOf(dialog.repo)) },
|
||||||
|
title = stringResource(R.string.delete_repo),
|
||||||
|
text = stringResource(R.string.delete_repo_confirmation, dialog.repo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
presenter.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is RepoPresenter.Event.RepoExists -> {
|
||||||
|
context.toast(R.string.error_repo_exists)
|
||||||
|
}
|
||||||
|
is RepoPresenter.Event.InternalError -> {
|
||||||
|
context.toast(R.string.internal_error)
|
||||||
|
}
|
||||||
|
is RepoPresenter.Event.InvalidName -> {
|
||||||
|
context.toast(R.string.invalid_repo_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package eu.kanade.presentation.category.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryContent(
|
||||||
|
categories: List<Category>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
onMoveUp: (Category) -> Unit,
|
||||||
|
onMoveDown: (Category) -> Unit,
|
||||||
|
onRename: (Category) -> Unit,
|
||||||
|
onDelete: (Category) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = paddingValues,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(categories) { index, category ->
|
||||||
|
CategoryListItem(
|
||||||
|
category = category,
|
||||||
|
canMoveUp = index != 0,
|
||||||
|
canMoveDown = index != categories.lastIndex,
|
||||||
|
onMoveUp = onMoveUp,
|
||||||
|
onMoveDown = onMoveDown,
|
||||||
|
onRename = onRename,
|
||||||
|
onDelete = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,129 @@
|
|||||||
|
package eu.kanade.presentation.category.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.components.TextButton
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryCreateDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
// SY -->
|
||||||
|
title: String,
|
||||||
|
extraMessage: String? = null,
|
||||||
|
// SY <--
|
||||||
|
) {
|
||||||
|
val (name, onNameChange) = remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onCreate(name)
|
||||||
|
onDismissRequest()
|
||||||
|
},) {
|
||||||
|
Text(text = stringResource(id = R.string.action_add))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(text = title)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
// SY -->
|
||||||
|
Column {
|
||||||
|
if (extraMessage != null) {
|
||||||
|
Text(extraMessage)
|
||||||
|
}
|
||||||
|
// SY <--
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = {
|
||||||
|
Text(text = stringResource(id = R.string.name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// SY -->
|
||||||
|
}
|
||||||
|
// SY <--
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryRenameDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
category: String,
|
||||||
|
) {
|
||||||
|
val (name, onNameChange) = remember { mutableStateOf(category) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onRename(name)
|
||||||
|
onDismissRequest()
|
||||||
|
},) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(text = stringResource(id = R.string.action_rename_category))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = {
|
||||||
|
Text(text = stringResource(id = R.string.name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryDeleteDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(R.string.no))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDelete()
|
||||||
|
onDismissRequest()
|
||||||
|
},) {
|
||||||
|
Text(text = stringResource(R.string.yes))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(text = title)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(text = text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
package eu.kanade.presentation.category.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Add
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.components.ExtendedFloatingActionButton
|
||||||
|
import eu.kanade.presentation.util.isScrolledToEnd
|
||||||
|
import eu.kanade.presentation.util.isScrollingUp
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryFloatingActionButton(
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = { Text(text = stringResource(id = R.string.action_add)) },
|
||||||
|
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = "") },
|
||||||
|
onClick = onCreate,
|
||||||
|
modifier = Modifier
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(),
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package eu.kanade.presentation.category.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowDropUp
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryListItem(
|
||||||
|
category: Category,
|
||||||
|
canMoveUp: Boolean,
|
||||||
|
canMoveDown: Boolean,
|
||||||
|
onMoveUp: (Category) -> Unit,
|
||||||
|
onMoveDown: (Category) -> Unit,
|
||||||
|
onRename: (Category) -> Unit,
|
||||||
|
onDelete: (Category) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
|
||||||
|
Text(text = category.name, modifier = Modifier.padding(start = horizontalPadding))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onMoveUp(category) },
|
||||||
|
enabled = canMoveUp,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onMoveDown(category) },
|
||||||
|
enabled = canMoveDown,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { onRename(category) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Edit, contentDescription = "")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onDelete(category) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package eu.kanade.presentation.category.components
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.SmallTopAppBar
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryTopAppBar(
|
||||||
|
topAppBarScrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
title: String,
|
||||||
|
) {
|
||||||
|
SmallTopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = navigateUp) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.abc_action_bar_up_description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(text = title)
|
||||||
|
},
|
||||||
|
scrollBehavior = topAppBarScrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
package eu.kanade.presentation.category.components.genre
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SortTagContent(
|
||||||
|
categories: List<String>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
onMoveUp: (String, Int) -> Unit,
|
||||||
|
onMoveDown: (String, Int) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = paddingValues,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(categories) { index, tag ->
|
||||||
|
SortTagListItem(
|
||||||
|
tag = tag,
|
||||||
|
index = index,
|
||||||
|
canMoveUp = index != 0,
|
||||||
|
canMoveDown = index != categories.lastIndex,
|
||||||
|
onMoveUp = onMoveUp,
|
||||||
|
onMoveDown = onMoveDown,
|
||||||
|
onDelete = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package eu.kanade.presentation.category.components.genre
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowDropUp
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SortTagListItem(
|
||||||
|
tag: String,
|
||||||
|
index: Int,
|
||||||
|
canMoveUp: Boolean,
|
||||||
|
canMoveDown: Boolean,
|
||||||
|
onMoveUp: (String, Int) -> Unit,
|
||||||
|
onMoveDown: (String, Int) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
|
||||||
|
Text(text = tag, modifier = Modifier.padding(start = horizontalPadding))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onMoveUp(tag, index) },
|
||||||
|
enabled = canMoveUp,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onMoveDown(tag, index) },
|
||||||
|
enabled = canMoveDown,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { onDelete(tag) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
package eu.kanade.presentation.category.components.repo
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceRepoContent(
|
||||||
|
repos: List<String>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = paddingValues,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(repos) { repo ->
|
||||||
|
SourceRepoListItem(
|
||||||
|
repo = repo,
|
||||||
|
onDelete = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package eu.kanade.presentation.category.components.repo
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceRepoListItem(
|
||||||
|
repo: String,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
|
||||||
|
Text(text = repo, modifier = Modifier.padding(start = horizontalPadding))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { onDelete(repo) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package eu.kanade.presentation.category.components.sources
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceCategoryContent(
|
||||||
|
categories: List<String>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = paddingValues,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(categories) { index, category ->
|
||||||
|
SourceCategoryListItem(
|
||||||
|
category = category,
|
||||||
|
onRename = onRename,
|
||||||
|
onDelete = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package eu.kanade.presentation.category.components.sources
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import eu.kanade.presentation.util.horizontalPadding
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceCategoryListItem(
|
||||||
|
category: String,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
|
||||||
|
Text(text = category, modifier = Modifier.padding(start = horizontalPadding))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { onRename(category) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Edit, contentDescription = "")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onDelete(category) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -60,8 +60,8 @@ fun ChapterDownloadIndicator(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val indicatorModifier = Modifier
|
val indicatorModifier = Modifier
|
||||||
.size(IndicatorSize)
|
.size(IndicatorSize)
|
||||||
.padding(IndicatorPadding)
|
.padding(IndicatorPadding)
|
||||||
if (isDownloaded) {
|
if (isDownloaded) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.CheckCircle,
|
imageVector = Icons.Default.CheckCircle,
|
||||||
@ -151,5 +151,6 @@ fun ChapterDownloadIndicator(
|
|||||||
|
|
||||||
private val IndicatorSize = 26.dp
|
private val IndicatorSize = 26.dp
|
||||||
private val IndicatorPadding = 2.dp
|
private val IndicatorPadding = 2.dp
|
||||||
|
|
||||||
// To match composable parameter name when used later
|
// To match composable parameter name when used later
|
||||||
private val IndicatorStrokeWidth = IndicatorPadding
|
private val IndicatorStrokeWidth = IndicatorPadding
|
||||||
|
@ -3,6 +3,12 @@ package eu.kanade.presentation.util
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
val horizontalPadding = 16.dp
|
private val horizontal = 16.dp
|
||||||
|
|
||||||
val topPaddingValues = PaddingValues(top = 8.dp)
|
private val vertical = 8.dp
|
||||||
|
|
||||||
|
val horizontalPadding = horizontal
|
||||||
|
|
||||||
|
val verticalPadding = vertical
|
||||||
|
|
||||||
|
val topPaddingValues = PaddingValues(top = vertical)
|
||||||
|
@ -230,8 +230,8 @@ class MergedSource : HttpSource() {
|
|||||||
val id = insertManga.await(
|
val id = insertManga.await(
|
||||||
Manga.create().copy(
|
Manga.create().copy(
|
||||||
source = mangaSourceId,
|
source = mangaSourceId,
|
||||||
url = mangaUrl
|
url = mangaUrl,
|
||||||
)
|
),
|
||||||
)!!
|
)!!
|
||||||
val newManga = getManga.await(id)!!
|
val newManga = getManga.await(id)!!
|
||||||
updateManga.awaitUpdateFromSource(newManga, source.getMangaDetails(newManga.toMangaInfo()), false)
|
updateManga.awaitUpdateFromSource(newManga, source.getMangaDetails(newManga.toMangaInfo()), false)
|
||||||
|
@ -59,7 +59,7 @@ class SourcesPresenter(
|
|||||||
// SY <--
|
// SY <--
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectLatestSources(sources: List<Source>, categories: Set<String>, showLatest: Boolean, showPin: Boolean) {
|
private fun collectLatestSources(sources: List<Source>, categories: List<String>, showLatest: Boolean, showPin: Boolean) {
|
||||||
val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
|
val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
|
||||||
// Sources without a lang defined will be placed at the end
|
// Sources without a lang defined will be placed at the end
|
||||||
when {
|
when {
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
|
||||||
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom adapter for categories.
|
|
||||||
*
|
|
||||||
* @param controller The containing controller.
|
|
||||||
*/
|
|
||||||
class CategoryAdapter(controller: CategoryController) :
|
|
||||||
FlexibleAdapter<CategoryItem>(null, controller, true) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
val onItemReleaseListener: OnItemReleaseListener = controller
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the active selections from the list and the model.
|
|
||||||
*/
|
|
||||||
override fun clearSelection() {
|
|
||||||
super.clearSelection()
|
|
||||||
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection of the given position.
|
|
||||||
*
|
|
||||||
* @param position The position to toggle.
|
|
||||||
*/
|
|
||||||
override fun toggleSelection(position: Int) {
|
|
||||||
super.toggleSelection(position)
|
|
||||||
getItem(position)?.isSelected = isSelected(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnItemReleaseListener {
|
|
||||||
/**
|
|
||||||
* Called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
fun onItemReleased(position: Int)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,357 +1,18 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
package eu.kanade.tachiyomi.ui.category
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.Menu
|
import eu.kanade.presentation.category.CategoryScreen
|
||||||
import android.view.MenuItem
|
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dev.chrisbanes.insetter.applyInsetter
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.SelectableAdapter
|
|
||||||
import eu.davidea.flexibleadapter.helpers.UndoHelper
|
|
||||||
import eu.kanade.domain.category.model.Category
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.FabController
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
|
||||||
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
class CategoryController : FullComposeController<CategoryPresenter>() {
|
||||||
* Controller to manage the categories for the users' library.
|
|
||||||
*/
|
|
||||||
class CategoryController :
|
|
||||||
NucleusController<CategoriesControllerBinding, CategoryPresenter>(),
|
|
||||||
FabController,
|
|
||||||
ActionMode.Callback,
|
|
||||||
FlexibleAdapter.OnItemClickListener,
|
|
||||||
FlexibleAdapter.OnItemLongClickListener,
|
|
||||||
CategoryAdapter.OnItemReleaseListener,
|
|
||||||
CategoryCreateDialog.Listener,
|
|
||||||
CategoryRenameDialog.Listener,
|
|
||||||
UndoHelper.OnActionListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object used to show ActionMode toolbar.
|
|
||||||
*/
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing category items.
|
|
||||||
*/
|
|
||||||
private var adapter: CategoryAdapter? = null
|
|
||||||
|
|
||||||
private var actionFab: ExtendedFloatingActionButton? = null
|
|
||||||
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo helper used for restoring a deleted category.
|
|
||||||
*/
|
|
||||||
private var undoHelper: UndoHelper? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the presenter for this controller. Not to be manually called.
|
|
||||||
*/
|
|
||||||
override fun createPresenter() = CategoryPresenter()
|
override fun createPresenter() = CategoryPresenter()
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Returns the toolbar title to show when this controller is attached.
|
override fun ComposeContent() {
|
||||||
*/
|
CategoryScreen(
|
||||||
override fun getTitle(): String? {
|
presenter = presenter,
|
||||||
return resources?.getString(R.string.action_edit_categories)
|
navigateUp = router::popCurrentController,
|
||||||
}
|
)
|
||||||
|
|
||||||
override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after view inflation. Used to initialize the view.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onViewCreated(view: View) {
|
|
||||||
super.onViewCreated(view)
|
|
||||||
|
|
||||||
binding.recycler.applyInsetter {
|
|
||||||
type(navigationBars = true) {
|
|
||||||
padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = CategoryAdapter(this@CategoryController)
|
|
||||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
|
||||||
binding.recycler.setHasFixedSize(true)
|
|
||||||
binding.recycler.adapter = adapter
|
|
||||||
adapter?.isHandleDragEnabled = true
|
|
||||||
adapter?.isPermanentDelete = false
|
|
||||||
|
|
||||||
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
|
|
||||||
|
|
||||||
viewScope.launch {
|
|
||||||
presenter.categories.collect {
|
|
||||||
setCategories(it.map(::CategoryItem))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
actionFab = fab
|
|
||||||
fab.setText(R.string.action_add)
|
|
||||||
fab.setIconResource(R.drawable.ic_add_24dp)
|
|
||||||
fab.setOnClickListener {
|
|
||||||
CategoryCreateDialog(this@CategoryController).showDialog(router, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
fab.setOnClickListener(null)
|
|
||||||
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
|
|
||||||
actionFab = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the view is being destroyed. Used to release references and remove callbacks.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onDestroyView(view: View) {
|
|
||||||
// Manually call callback to delete categories if required
|
|
||||||
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
|
|
||||||
undoHelper = null
|
|
||||||
actionMode = null
|
|
||||||
adapter = null
|
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when the categories are updated.
|
|
||||||
*
|
|
||||||
* @param categories The new list of categories to display.
|
|
||||||
*/
|
|
||||||
fun setCategories(categories: List<CategoryItem>) {
|
|
||||||
actionMode?.finish()
|
|
||||||
adapter?.updateDataSet(categories)
|
|
||||||
if (categories.isNotEmpty()) {
|
|
||||||
binding.emptyView.hide()
|
|
||||||
val selected = categories.filter { it.isSelected }
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
selected.forEach { onItemLongClick(categories.indexOf(it)) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.emptyView.show(R.string.information_empty_category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when action mode is first created. The menu supplied will be used to generate action
|
|
||||||
* buttons for the action mode.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being created.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the action mode should be created, false if entering this mode should be
|
|
||||||
* aborted.
|
|
||||||
*/
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
// Inflate menu.
|
|
||||||
mode.menuInflater.inflate(R.menu.category_selection, menu)
|
|
||||||
// Enable adapter multi selection.
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.MULTI
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to refresh an action mode's action menu whenever it is invalidated.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being prepared.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the menu or action mode was updated, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
val count = adapter.selectedItemCount
|
|
||||||
mode.title = count.toString()
|
|
||||||
|
|
||||||
// Show edit button only when one item is selected
|
|
||||||
val editItem = mode.menu.findItem(R.id.action_edit)
|
|
||||||
editItem.isVisible = count == 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to report a user click on an action button.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode.
|
|
||||||
* @param item The item that was clicked.
|
|
||||||
* @return true if this callback handled the event, false if the standard MenuItem invocation
|
|
||||||
* should continue.
|
|
||||||
*/
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_delete -> {
|
|
||||||
undoHelper = UndoHelper(adapter, this)
|
|
||||||
undoHelper?.start(
|
|
||||||
adapter.selectedPositions,
|
|
||||||
(activity as? MainActivity)?.binding?.rootCoordinator!!,
|
|
||||||
R.string.snack_categories_deleted,
|
|
||||||
R.string.action_undo,
|
|
||||||
4000,
|
|
||||||
)
|
|
||||||
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
R.id.action_edit -> {
|
|
||||||
// Edit selected category
|
|
||||||
if (adapter.selectedItemCount == 1) {
|
|
||||||
val position = adapter.selectedPositions.first()
|
|
||||||
val category = adapter.getItem(position)?.category
|
|
||||||
if (category != null) {
|
|
||||||
editCategory(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an action mode is about to be exited and destroyed.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode being destroyed.
|
|
||||||
*/
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
// Reset adapter to single selection
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.IDLE
|
|
||||||
adapter?.clearSelection()
|
|
||||||
actionMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
* @return true if this click should enable selection mode.
|
|
||||||
*/
|
|
||||||
override fun onItemClick(view: View, position: Int): Boolean {
|
|
||||||
// Check if action mode is initialized and selected item exist.
|
|
||||||
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
|
|
||||||
toggleSelection(position)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is long clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
*/
|
|
||||||
override fun onItemLongClick(position: Int) {
|
|
||||||
val activity = activity as? AppCompatActivity ?: return
|
|
||||||
|
|
||||||
// Check if action mode is initialized.
|
|
||||||
if (actionMode == null) {
|
|
||||||
// Initialize action mode
|
|
||||||
actionMode = activity.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set item as selected
|
|
||||||
toggleSelection(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the selection state of an item.
|
|
||||||
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
|
|
||||||
*
|
|
||||||
* @param position The position of the item to toggle.
|
|
||||||
*/
|
|
||||||
private fun toggleSelection(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
|
|
||||||
// Mark the position selected
|
|
||||||
adapter.toggleSelection(position)
|
|
||||||
|
|
||||||
if (adapter.selectedItemCount == 0) {
|
|
||||||
actionMode?.finish()
|
|
||||||
} else {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item is released from a drag.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
val categories = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.category }
|
|
||||||
presenter.reorderCategories(categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the undo action is clicked in the snackbar.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
*/
|
|
||||||
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
|
|
||||||
adapter?.restoreDeletedItems()
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the time to restore the items expires.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
* @param event The event that triggered the action
|
|
||||||
*/
|
|
||||||
override fun onActionConfirmed(action: Int, event: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
presenter.deleteCategories(adapter.deletedItems.map { it.category })
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a dialog to let the user change the category name.
|
|
||||||
*
|
|
||||||
* @param category The category to be edited.
|
|
||||||
*/
|
|
||||||
private fun editCategory(category: Category) {
|
|
||||||
CategoryRenameDialog(this, category).showDialog(router)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames the given category with the given name.
|
|
||||||
*
|
|
||||||
* @param category The category to rename.
|
|
||||||
* @param name The new name of the category.
|
|
||||||
*/
|
|
||||||
override fun renameCategory(category: Category, name: String) {
|
|
||||||
presenter.renameCategory(category, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new category with the given name.
|
|
||||||
*
|
|
||||||
* @param name The name of the new category.
|
|
||||||
*/
|
|
||||||
override fun createCategory(name: String) {
|
|
||||||
presenter.createCategory(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a category with the given name already exists.
|
|
||||||
*/
|
|
||||||
fun onCategoryExistsError() {
|
|
||||||
activity?.toast(R.string.error_category_exists)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,48 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to create a new category for the library.
|
|
||||||
*/
|
|
||||||
class CategoryCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : CategoryCreateDialog.Listener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new category. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T) : this() {
|
|
||||||
targetController = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_add_category)
|
|
||||||
.setTextInput(prefill = currentName) {
|
|
||||||
currentName = it
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
(targetController as? Listener)?.createCategory(currentName)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun createCategory(name: String)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import eu.davidea.viewholders.FlexibleViewHolder
|
|
||||||
import eu.kanade.domain.category.model.Category
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holder used to display category items.
|
|
||||||
*
|
|
||||||
* @param view The view used by category items.
|
|
||||||
* @param adapter The adapter containing this holder.
|
|
||||||
*/
|
|
||||||
class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = CategoriesItemBinding.bind(view)
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDragHandleView(binding.reorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds this holder with the given category.
|
|
||||||
*
|
|
||||||
* @param category The category to bind.
|
|
||||||
*/
|
|
||||||
fun bind(category: Category) {
|
|
||||||
binding.title.text = category.name
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item is released.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
super.onItemReleased(position)
|
|
||||||
adapter.onItemReleaseListener.onItemReleased(position)
|
|
||||||
binding.container.isDragged = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionStateChanged(position: Int, actionState: Int) {
|
|
||||||
super.onActionStateChanged(position, actionState)
|
|
||||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
|
||||||
binding.container.isDragged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.domain.category.model.Category
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category item for a recycler view.
|
|
||||||
*/
|
|
||||||
class CategoryItem(val category: Category) : AbstractFlexibleItem<CategoryHolder>() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this item is currently selected.
|
|
||||||
*/
|
|
||||||
var isSelected = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the layout resource for this item.
|
|
||||||
*/
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.categories_item
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new view holder for this item.
|
|
||||||
*
|
|
||||||
* @param view The view of this item.
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
*/
|
|
||||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): CategoryHolder {
|
|
||||||
return CategoryHolder(view, adapter as CategoryAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the given view holder with this item.
|
|
||||||
*
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
* @param holder The holder to bind.
|
|
||||||
* @param position The position of this item in the adapter.
|
|
||||||
* @param payloads List of partial changes.
|
|
||||||
*/
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: CategoryHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<Any?>?,
|
|
||||||
) {
|
|
||||||
holder.bind(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this item is draggable.
|
|
||||||
*/
|
|
||||||
override fun isDraggable(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other is CategoryItem) {
|
|
||||||
return category.id == other.category.id
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return category.id.hashCode()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,130 +1,91 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
package eu.kanade.tachiyomi.ui.category
|
||||||
|
|
||||||
import android.os.Bundle
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import eu.kanade.domain.category.interactor.CreateCategoryWithName
|
||||||
import eu.kanade.domain.category.interactor.DeleteCategory
|
import eu.kanade.domain.category.interactor.DeleteCategory
|
||||||
import eu.kanade.domain.category.interactor.GetCategories
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
import eu.kanade.domain.category.interactor.InsertCategory
|
import eu.kanade.domain.category.interactor.RenameCategory
|
||||||
import eu.kanade.domain.category.interactor.UpdateCategory
|
import eu.kanade.domain.category.interactor.ReorderCategory
|
||||||
import eu.kanade.domain.category.model.Category
|
import eu.kanade.domain.category.model.Category
|
||||||
import eu.kanade.domain.category.model.CategoryUpdate
|
|
||||||
import eu.kanade.domain.category.repository.DuplicateNameException
|
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
import kotlinx.coroutines.channels.Channel
|
||||||
import eu.kanade.tachiyomi.util.system.logcat
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import logcat.LogPriority
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
/**
|
|
||||||
* Presenter of [CategoryController]. Used to manage the categories of the library.
|
|
||||||
*/
|
|
||||||
class CategoryPresenter(
|
class CategoryPresenter(
|
||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
private val insertCategory: InsertCategory = Injekt.get(),
|
private val createCategoryWithName: CreateCategoryWithName = Injekt.get(),
|
||||||
private val updateCategory: UpdateCategory = Injekt.get(),
|
private val renameCategory: RenameCategory = Injekt.get(),
|
||||||
|
private val reorderCategory: ReorderCategory = Injekt.get(),
|
||||||
private val deleteCategory: DeleteCategory = Injekt.get(),
|
private val deleteCategory: DeleteCategory = Injekt.get(),
|
||||||
) : BasePresenter<CategoryController>() {
|
) : BasePresenter<CategoryController>() {
|
||||||
|
|
||||||
private val _categories: MutableStateFlow<List<Category>> = MutableStateFlow(listOf())
|
var dialog: Dialog? by mutableStateOf(null)
|
||||||
val categories = _categories.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
val categories = getCategories.subscribe()
|
||||||
* Called when the presenter is created.
|
|
||||||
*
|
|
||||||
* @param savedState The saved state of this presenter.
|
|
||||||
*/
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
|
||||||
super.onCreate(savedState)
|
|
||||||
|
|
||||||
presenterScope.launchIO {
|
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||||
getCategories.subscribe()
|
val events = _events.consumeAsFlow()
|
||||||
.collectLatest { list ->
|
|
||||||
_categories.value = list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and adds a new category to the database.
|
|
||||||
*
|
|
||||||
* @param name The name of the category to create.
|
|
||||||
*/
|
|
||||||
fun createCategory(name: String) {
|
fun createCategory(name: String) {
|
||||||
presenterScope.launchIO {
|
presenterScope.launchIO {
|
||||||
val result = insertCategory.await(
|
when (createCategoryWithName.await(name)) {
|
||||||
name = name,
|
is CreateCategoryWithName.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
|
||||||
order = categories.value.map { it.order + 1L }.maxOrNull() ?: 0L,
|
is CreateCategoryWithName.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
)
|
else -> {}
|
||||||
when (result) {
|
|
||||||
is InsertCategory.Result.Success -> {}
|
|
||||||
is InsertCategory.Result.Error -> {
|
|
||||||
logcat(LogPriority.ERROR, result.error)
|
|
||||||
if (result.error is DuplicateNameException) {
|
|
||||||
launchUI { view?.onCategoryExistsError() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun deleteCategory(category: Category) {
|
||||||
* Deletes the given categories from the database.
|
|
||||||
*
|
|
||||||
* @param categories The list of categories to delete.
|
|
||||||
*/
|
|
||||||
fun deleteCategories(categories: List<Category>) {
|
|
||||||
presenterScope.launchIO {
|
presenterScope.launchIO {
|
||||||
categories.forEach { category ->
|
when (deleteCategory.await(category.id)) {
|
||||||
deleteCategory.await(category.id)
|
is DeleteCategory.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun moveUp(category: Category) {
|
||||||
* Reorders the given categories in the database.
|
|
||||||
*
|
|
||||||
* @param categories The list of categories to reorder.
|
|
||||||
*/
|
|
||||||
fun reorderCategories(categories: List<Category>) {
|
|
||||||
presenterScope.launchIO {
|
presenterScope.launchIO {
|
||||||
categories.forEachIndexed { order, category ->
|
when (reorderCategory.await(category, category.order - 1)) {
|
||||||
updateCategory.await(
|
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
payload = CategoryUpdate(
|
else -> {}
|
||||||
id = category.id,
|
}
|
||||||
order = order.toLong(),
|
}
|
||||||
),
|
}
|
||||||
)
|
|
||||||
|
fun moveDown(category: Category) {
|
||||||
|
presenterScope.launchIO {
|
||||||
|
when (reorderCategory.await(category, category.order + 1)) {
|
||||||
|
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames a category.
|
|
||||||
*
|
|
||||||
* @param category The category to rename.
|
|
||||||
* @param name The new name of the category.
|
|
||||||
*/
|
|
||||||
fun renameCategory(category: Category, name: String) {
|
fun renameCategory(category: Category, name: String) {
|
||||||
presenterScope.launchIO {
|
presenterScope.launchIO {
|
||||||
val result = updateCategory.await(
|
when (renameCategory.await(category, name)) {
|
||||||
payload = CategoryUpdate(
|
RenameCategory.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
|
||||||
id = category.id,
|
is RenameCategory.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
name = name,
|
else -> {}
|
||||||
),
|
|
||||||
)
|
|
||||||
when (result) {
|
|
||||||
is UpdateCategory.Result.Success -> {}
|
|
||||||
is UpdateCategory.Result.Error -> {
|
|
||||||
logcat(LogPriority.ERROR, result.error)
|
|
||||||
if (result.error is DuplicateNameException) {
|
|
||||||
launchUI { view?.onCategoryExistsError() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class Dialog {
|
||||||
|
object Create : Dialog()
|
||||||
|
data class Rename(val category: Category) : Dialog()
|
||||||
|
data class Delete(val category: Category) : Dialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Event {
|
||||||
|
object CategoryWithNameAlreadyExists : Event()
|
||||||
|
object InternalError : Event()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,83 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.domain.category.model.Category
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to rename an existing category of the library.
|
|
||||||
*/
|
|
||||||
class CategoryRenameDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : CategoryRenameDialog.Listener {
|
|
||||||
|
|
||||||
private var category: Category? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new category. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T, category: Category) : this() {
|
|
||||||
targetController = target
|
|
||||||
this.category = category
|
|
||||||
currentName = category.name
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_rename_category)
|
|
||||||
.setTextInput(prefill = currentName) {
|
|
||||||
currentName = it
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ -> onPositive() }
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to save this Controller's state in the event that its host Activity is destroyed.
|
|
||||||
*
|
|
||||||
* @param outState The Bundle into which data should be saved
|
|
||||||
*/
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
outState.putSerializable(CATEGORY_KEY, category)
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores data that was saved in the [onSaveInstanceState] method.
|
|
||||||
*
|
|
||||||
* @param savedInstanceState The bundle that has data to be restored
|
|
||||||
*/
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
category = savedInstanceState.getSerializable(CATEGORY_KEY) as? Category
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the positive button of the dialog is clicked.
|
|
||||||
*/
|
|
||||||
private fun onPositive() {
|
|
||||||
val target = targetController as? Listener ?: return
|
|
||||||
val category = category ?: return
|
|
||||||
|
|
||||||
target.renameCategory(category, currentName)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun renameCategory(category: Category, name: String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val CATEGORY_KEY = "CategoryRenameDialog.category"
|
|
@ -25,12 +25,10 @@ import eu.kanade.tachiyomi.util.view.shrinkOnScroll
|
|||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller to manage the lock times for the biometric lock.
|
* Controller to manage the lock times for the biometric lock.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
class BiometricTimesController :
|
class BiometricTimesController :
|
||||||
NucleusController<CategoriesControllerBinding, BiometricTimesPresenter>(),
|
NucleusController<CategoriesControllerBinding, BiometricTimesPresenter>(),
|
||||||
FabController,
|
FabController,
|
||||||
|
@ -6,12 +6,10 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
|
|||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
import eu.davidea.flexibleadapter.items.IFlexible
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category item for a recycler view.
|
* Category item for a recycler view.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
class BiometricTimesItem(val timeRange: TimeRange) : AbstractFlexibleItem<BiometricTimesHolder>() {
|
class BiometricTimesItem(val timeRange: TimeRange) : AbstractFlexibleItem<BiometricTimesHolder>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -3,20 +3,17 @@ package eu.kanade.tachiyomi.ui.category.biometric
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
import eu.kanade.tachiyomi.util.preference.plusAssign
|
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||||
import exh.log.xLogD
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import rx.Observable
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenter of [BiometricTimesController]. Used to manage the categories of the library.
|
* Presenter of [BiometricTimesController]. Used to manage the categories of the library.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
|
class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,12 +33,11 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
|
|||||||
|
|
||||||
preferences.authenticatorTimeRanges().asFlow().onEach { prefTimeRanges ->
|
preferences.authenticatorTimeRanges().asFlow().onEach { prefTimeRanges ->
|
||||||
timeRanges = prefTimeRanges.toList()
|
timeRanges = prefTimeRanges.toList()
|
||||||
.mapNotNull { TimeRange.fromPreferenceString(it) }.onEach { xLogD(it) }
|
.mapNotNull(TimeRange::fromPreferenceString)
|
||||||
|
|
||||||
Observable.just(timeRanges)
|
withUIContext {
|
||||||
.map { it.map(::BiometricTimesItem) }
|
view?.setBiometricTimeItems(timeRanges.map(::BiometricTimesItem))
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
}
|
||||||
.subscribeLatestCache(BiometricTimesController::setBiometricTimeItems)
|
|
||||||
}.launchIn(presenterScope)
|
}.launchIn(presenterScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,12 +49,12 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
|
|||||||
fun createTimeRange(timeRange: TimeRange) {
|
fun createTimeRange(timeRange: TimeRange) {
|
||||||
// Do not allow duplicate categories.
|
// Do not allow duplicate categories.
|
||||||
if (timeRangeConflicts(timeRange)) {
|
if (timeRangeConflicts(timeRange)) {
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onTimeRangeConflictsError() })
|
launchUI {
|
||||||
|
view?.onTimeRangeConflictsError()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
xLogD(timeRange)
|
|
||||||
|
|
||||||
preferences.authenticatorTimeRanges() += timeRange.toPreferenceString()
|
preferences.authenticatorTimeRanges() += timeRange.toPreferenceString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +65,7 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
|
|||||||
*/
|
*/
|
||||||
fun deleteTimeRanges(timeRanges: List<TimeRange>) {
|
fun deleteTimeRanges(timeRanges: List<TimeRange>) {
|
||||||
preferences.authenticatorTimeRanges().set(
|
preferences.authenticatorTimeRanges().set(
|
||||||
this.timeRanges.filterNot { it in timeRanges }.map { it.toPreferenceString() }.toSet(),
|
this.timeRanges.filterNot { it in timeRanges }.map(TimeRange::toPreferenceString).toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,11 +40,13 @@ data class TimeRange(private val startTime: Duration, private val endTime: Durat
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromPreferenceString(timeRange: String): TimeRange? {
|
fun fromPreferenceString(timeRange: String): TimeRange? {
|
||||||
return timeRange.split(",").mapNotNull { it.toDoubleOrNull() }.let {
|
val index = timeRange.indexOf(',')
|
||||||
if (it.size != 2) null else {
|
return if (index != -1) {
|
||||||
TimeRange(it[0].minutes, it[1].minutes)
|
TimeRange(
|
||||||
}
|
timeRange.substring(0, index).toDoubleOrNull()?.minutes ?: return null,
|
||||||
}
|
timeRange.substring(index + 1).toDoubleOrNull()?.minutes ?: return null,
|
||||||
|
)
|
||||||
|
} else return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
|
||||||
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom adapter for categories.
|
|
||||||
*
|
|
||||||
* @param controller The containing controller.
|
|
||||||
*/
|
|
||||||
class SortTagAdapter(controller: SortTagController) :
|
|
||||||
FlexibleAdapter<SortTagItem>(null, controller, true) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
val onItemReleaseListener: OnItemReleaseListener = controller
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the active selections from the list and the model.
|
|
||||||
*/
|
|
||||||
override fun clearSelection() {
|
|
||||||
super.clearSelection()
|
|
||||||
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection of the given position.
|
|
||||||
*
|
|
||||||
* @param position The position to toggle.
|
|
||||||
*/
|
|
||||||
override fun toggleSelection(position: Int) {
|
|
||||||
super.toggleSelection(position)
|
|
||||||
getItem(position)?.isSelected = isSelected(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnItemReleaseListener {
|
|
||||||
/**
|
|
||||||
* Called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
fun onItemReleased(position: Int)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,357 +1,21 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
package eu.kanade.tachiyomi.ui.category.genre
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.Menu
|
import eu.kanade.presentation.category.SortTagScreen
|
||||||
import android.view.MenuInflater
|
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dev.chrisbanes.insetter.applyInsetter
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.SelectableAdapter
|
|
||||||
import eu.davidea.flexibleadapter.helpers.UndoHelper
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.FabController
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
|
||||||
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller to manage the categories for the users' library.
|
* Controller to manage the categories for the users' library.
|
||||||
*/
|
*/
|
||||||
class SortTagController :
|
class SortTagController : FullComposeController<SortTagPresenter>() {
|
||||||
NucleusController<CategoriesControllerBinding, SortTagPresenter>(),
|
|
||||||
FabController,
|
|
||||||
ActionMode.Callback,
|
|
||||||
FlexibleAdapter.OnItemClickListener,
|
|
||||||
FlexibleAdapter.OnItemLongClickListener,
|
|
||||||
SortTagAdapter.OnItemReleaseListener,
|
|
||||||
SortTagCreateDialog.Listener,
|
|
||||||
UndoHelper.OnActionListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object used to show ActionMode toolbar.
|
|
||||||
*/
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
|
|
||||||
private var shownHelpDialog = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing category items.
|
|
||||||
*/
|
|
||||||
private var adapter: SortTagAdapter? = null
|
|
||||||
|
|
||||||
private var actionFab: ExtendedFloatingActionButton? = null
|
|
||||||
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo helper used for restoring a deleted category.
|
|
||||||
*/
|
|
||||||
private var undoHelper: UndoHelper? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the presenter for this controller. Not to be manually called.
|
|
||||||
*/
|
|
||||||
override fun createPresenter() = SortTagPresenter()
|
override fun createPresenter() = SortTagPresenter()
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Returns the toolbar title to show when this controller is attached.
|
override fun ComposeContent() {
|
||||||
*/
|
SortTagScreen(
|
||||||
override fun getTitle(): String? {
|
presenter = presenter,
|
||||||
return resources?.getString(R.string.action_edit_tags)
|
navigateUp = router::popCurrentController,
|
||||||
}
|
)
|
||||||
|
|
||||||
init {
|
|
||||||
setHasOptionsMenu(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after view inflation. Used to initialize the view.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onViewCreated(view: View) {
|
|
||||||
super.onViewCreated(view)
|
|
||||||
|
|
||||||
binding.recycler.applyInsetter {
|
|
||||||
type(navigationBars = true) {
|
|
||||||
padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = SortTagAdapter(this@SortTagController)
|
|
||||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
|
||||||
binding.recycler.setHasFixedSize(true)
|
|
||||||
binding.recycler.adapter = adapter
|
|
||||||
adapter?.isHandleDragEnabled = true
|
|
||||||
adapter?.isPermanentDelete = false
|
|
||||||
|
|
||||||
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
actionFab = fab
|
|
||||||
fab.setText(R.string.action_add)
|
|
||||||
fab.setIconResource(R.drawable.ic_add_24dp)
|
|
||||||
fab.setOnClickListener {
|
|
||||||
if (!shownHelpDialog) {
|
|
||||||
shownHelpDialog = true
|
|
||||||
helpDialog(true)
|
|
||||||
} else {
|
|
||||||
SortTagCreateDialog(this@SortTagController).showDialog(router, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
fab.setOnClickListener(null)
|
|
||||||
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
|
|
||||||
actionFab = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the view is being destroyed. Used to release references and remove callbacks.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onDestroyView(view: View) {
|
|
||||||
// Manually call callback to delete categories if required
|
|
||||||
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
|
|
||||||
undoHelper = null
|
|
||||||
actionMode = null
|
|
||||||
adapter = null
|
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when the categories are updated.
|
|
||||||
*
|
|
||||||
* @param categories The new list of categories to display.
|
|
||||||
*/
|
|
||||||
fun setCategories(categories: List<SortTagItem>) {
|
|
||||||
actionMode?.finish()
|
|
||||||
adapter?.updateDataSet(categories)
|
|
||||||
if (categories.isNotEmpty()) {
|
|
||||||
binding.emptyView.hide()
|
|
||||||
val selected = categories.filter { it.isSelected }
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
selected.forEach { onItemLongClick(categories.indexOf(it)) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.emptyView.show(R.string.information_empty_tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
|
||||||
inflater.inflate(R.menu.sort_tags, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_help -> {
|
|
||||||
shownHelpDialog = true
|
|
||||||
helpDialog()
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when action mode is first created. The menu supplied will be used to generate action
|
|
||||||
* buttons for the action mode.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being created.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the action mode should be created, false if entering this mode should be
|
|
||||||
* aborted.
|
|
||||||
*/
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
// Inflate menu.
|
|
||||||
mode.menuInflater.inflate(R.menu.category_selection, menu)
|
|
||||||
// Enable adapter multi selection.
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.MULTI
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to refresh an action mode's action menu whenever it is invalidated.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being prepared.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the menu or action mode was updated, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
val count = adapter.selectedItemCount
|
|
||||||
mode.title = count.toString()
|
|
||||||
|
|
||||||
// Show edit button only when one item is selected
|
|
||||||
mode.menu.findItem(R.id.action_edit).isVisible = false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to report a user click on an action button.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode.
|
|
||||||
* @param item The item that was clicked.
|
|
||||||
* @return true if this callback handled the event, false if the standard MenuItem invocation
|
|
||||||
* should continue.
|
|
||||||
*/
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_delete -> {
|
|
||||||
undoHelper = UndoHelper(adapter, this)
|
|
||||||
undoHelper?.start(
|
|
||||||
adapter.selectedPositions,
|
|
||||||
(activity as? MainActivity)?.binding?.rootCoordinator!!,
|
|
||||||
R.string.snack_tags_deleted,
|
|
||||||
R.string.action_undo,
|
|
||||||
3000,
|
|
||||||
)
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun helpDialog(hasPositive: Boolean = false) {
|
|
||||||
MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.add_tag)
|
|
||||||
.setMessage(R.string.action_add_tags_message)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
if (hasPositive) {
|
|
||||||
SortTagCreateDialog(this@SortTagController).showDialog(router, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an action mode is about to be exited and destroyed.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode being destroyed.
|
|
||||||
*/
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
// Reset adapter to single selection
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.IDLE
|
|
||||||
adapter?.clearSelection()
|
|
||||||
actionMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
* @return true if this click should enable selection mode.
|
|
||||||
*/
|
|
||||||
override fun onItemClick(view: View, position: Int): Boolean {
|
|
||||||
// Check if action mode is initialized and selected item exist.
|
|
||||||
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
|
|
||||||
toggleSelection(position)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is long clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
*/
|
|
||||||
override fun onItemLongClick(position: Int) {
|
|
||||||
val activity = activity as? AppCompatActivity ?: return
|
|
||||||
|
|
||||||
// Check if action mode is initialized.
|
|
||||||
if (actionMode == null) {
|
|
||||||
// Initialize action mode
|
|
||||||
actionMode = activity.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set item as selected
|
|
||||||
toggleSelection(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the selection state of an item.
|
|
||||||
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
|
|
||||||
*
|
|
||||||
* @param position The position of the item to toggle.
|
|
||||||
*/
|
|
||||||
private fun toggleSelection(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
|
|
||||||
// Mark the position selected
|
|
||||||
adapter.toggleSelection(position)
|
|
||||||
|
|
||||||
if (adapter.selectedItemCount == 0) {
|
|
||||||
actionMode?.finish()
|
|
||||||
} else {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item is released from a drag.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
val tags = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.tag }
|
|
||||||
presenter.reorderTags(tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the undo action is clicked in the snackbar.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
*/
|
|
||||||
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
|
|
||||||
adapter?.restoreDeletedItems()
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the time to restore the items expires.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
* @param event The event that triggered the action
|
|
||||||
*/
|
|
||||||
override fun onActionConfirmed(action: Int, event: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
presenter.deleteTags(adapter.deletedItems.map { it.tag })
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new category with the given name.
|
|
||||||
*
|
|
||||||
* @param name The name of the new category.
|
|
||||||
*/
|
|
||||||
override fun createCategory(name: String) {
|
|
||||||
presenter.createTag(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a category with the given name already exists.
|
|
||||||
*/
|
|
||||||
fun onTagExistsError() {
|
|
||||||
activity?.toast(R.string.error_tag_exists)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,51 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to create a new category for the library.
|
|
||||||
*/
|
|
||||||
class SortTagCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : SortTagCreateDialog.Listener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new category. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T) : this() {
|
|
||||||
targetController = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_add_category)
|
|
||||||
.setTextInput(
|
|
||||||
hint = resources?.getString(R.string.name),
|
|
||||||
prefill = currentName,
|
|
||||||
) { input ->
|
|
||||||
currentName = input
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
(targetController as? Listener)?.createCategory(currentName)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun createCategory(name: String)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import eu.davidea.viewholders.FlexibleViewHolder
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holder used to display category items.
|
|
||||||
*
|
|
||||||
* @param view The view used by category items.
|
|
||||||
* @param adapter The adapter containing this holder.
|
|
||||||
*/
|
|
||||||
class SortTagHolder(view: View, val adapter: SortTagAdapter) : FlexibleViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = CategoriesItemBinding.bind(view)
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDragHandleView(binding.reorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds this holder with the given category.
|
|
||||||
*
|
|
||||||
* @param tag The tag to bind.
|
|
||||||
*/
|
|
||||||
fun bind(tag: String) {
|
|
||||||
binding.title.text = tag
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item is released.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
super.onItemReleased(position)
|
|
||||||
adapter.onItemReleaseListener.onItemReleased(position)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category item for a recycler view.
|
|
||||||
*/
|
|
||||||
class SortTagItem(val tag: String) : AbstractFlexibleItem<SortTagHolder>() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this item is currently selected.
|
|
||||||
*/
|
|
||||||
var isSelected = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the layout resource for this item.
|
|
||||||
*/
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.categories_item
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new view holder for this item.
|
|
||||||
*
|
|
||||||
* @param view The view of this item.
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
*/
|
|
||||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SortTagHolder {
|
|
||||||
return SortTagHolder(view, adapter as SortTagAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the given view holder with this item.
|
|
||||||
*
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
* @param holder The holder to bind.
|
|
||||||
* @param position The position of this item in the adapter.
|
|
||||||
* @param payloads List of partial changes.
|
|
||||||
*/
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: SortTagHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<Any?>?,
|
|
||||||
) {
|
|
||||||
holder.bind(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this item is draggable.
|
|
||||||
*/
|
|
||||||
override fun isDraggable(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other is SortTagItem) {
|
|
||||||
return tag == other.tag
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return tag.hashCode()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,93 +1,79 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.genre
|
package eu.kanade.tachiyomi.ui.category.genre
|
||||||
|
|
||||||
import android.os.Bundle
|
import androidx.compose.runtime.getValue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import eu.kanade.domain.manga.interactor.CreateSortTag
|
||||||
|
import eu.kanade.domain.manga.interactor.DeleteSortTag
|
||||||
|
import eu.kanade.domain.manga.interactor.GetSortTag
|
||||||
|
import eu.kanade.domain.manga.interactor.ReorderSortTag
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.util.preference.minusAssign
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import eu.kanade.tachiyomi.util.preference.plusAssign
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import rx.Observable
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenter of [SortTagController]. Used to manage the categories of the library.
|
* Presenter of [SortTagController]. Used to manage the categories of the library.
|
||||||
*/
|
*/
|
||||||
class SortTagPresenter : BasePresenter<SortTagController>() {
|
class SortTagPresenter(
|
||||||
|
private val getSortTag: GetSortTag = Injekt.get(),
|
||||||
|
private val createSortTag: CreateSortTag = Injekt.get(),
|
||||||
|
private val deleteSortTag: DeleteSortTag = Injekt.get(),
|
||||||
|
private val reorderSortTag: ReorderSortTag = Injekt.get(),
|
||||||
|
) : BasePresenter<SortTagController>() {
|
||||||
|
|
||||||
|
var dialog: Dialog? by mutableStateOf(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List containing categories.
|
* List containing categories.
|
||||||
*/
|
*/
|
||||||
private var tags: List<Pair<Int, String>> = emptyList()
|
val tags = getSortTag.subscribe()
|
||||||
|
|
||||||
val preferences: PreferencesHelper = Injekt.get()
|
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||||
|
val events = _events.consumeAsFlow()
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the presenter is created.
|
|
||||||
*
|
|
||||||
* @param savedState The saved state of this presenter.
|
|
||||||
*/
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
|
||||||
super.onCreate(savedState)
|
|
||||||
|
|
||||||
preferences.sortTagsForLibrary().asFlow().onEach { tags ->
|
|
||||||
this.tags = tags.map { it.split("|") }
|
|
||||||
.mapNotNull { (it.getOrNull(0)?.toIntOrNull() ?: return@mapNotNull null) to (it.getOrNull(1) ?: return@mapNotNull null) }
|
|
||||||
.sortedBy { it.first }
|
|
||||||
|
|
||||||
Observable.just(this.tags)
|
|
||||||
.map { tagPairs -> tagPairs.map { it.second }.map(::SortTagItem) }
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribeLatestCache(SortTagController::setCategories)
|
|
||||||
}.launchIn(presenterScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and adds a new category to the database.
|
|
||||||
*
|
|
||||||
* @param name The name of the category to create.
|
|
||||||
*/
|
|
||||||
fun createTag(name: String) {
|
fun createTag(name: String) {
|
||||||
// Do not allow duplicate categories.
|
presenterScope.launchIO {
|
||||||
if (tagExists(name.trim())) {
|
when (createSortTag.await(name)) {
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onTagExistsError() })
|
is CreateSortTag.Result.TagExists -> _events.send(Event.TagExists)
|
||||||
return
|
else -> {}
|
||||||
}
|
|
||||||
|
|
||||||
val size = preferences.sortTagsForLibrary().get().size
|
|
||||||
|
|
||||||
preferences.sortTagsForLibrary() += "$size|${name.trim()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the given categories from the database.
|
|
||||||
*
|
|
||||||
* @param tags The list of categories to delete.
|
|
||||||
*/
|
|
||||||
fun deleteTags(tags: List<String>) {
|
|
||||||
val preferenceTags = preferences.sortTagsForLibrary().get()
|
|
||||||
tags.forEach { tag ->
|
|
||||||
preferenceTags.firstOrNull { it.endsWith(tag) }?.let {
|
|
||||||
preferences.sortTagsForLibrary() -= it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun delete(tag: String) {
|
||||||
* Reorders the given categories in the database.
|
presenterScope.launchIO {
|
||||||
*
|
deleteSortTag.await(tag)
|
||||||
* @param tags The list of categories to reorder.
|
}
|
||||||
*/
|
|
||||||
fun reorderTags(tags: List<String>) {
|
|
||||||
preferences.sortTagsForLibrary().set(tags.mapIndexed { index, tag -> "$index|$tag" }.toSet())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun moveUp(tag: String, index: Int) {
|
||||||
* Returns true if a category with the given name already exists.
|
presenterScope.launchIO {
|
||||||
*/
|
when (reorderSortTag.await(tag, index - 1)) {
|
||||||
private fun tagExists(name: String): Boolean {
|
is ReorderSortTag.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
return tags.any { it.equals(name) }
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveDown(tag: String, index: Int) {
|
||||||
|
presenterScope.launchIO {
|
||||||
|
when (reorderSortTag.await(tag, index + 1)) {
|
||||||
|
is ReorderSortTag.Result.InternalError -> _events.send(Event.InternalError)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Event {
|
||||||
|
object TagExists : Event()
|
||||||
|
object InternalError : Event()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Dialog {
|
||||||
|
object Create : Dialog()
|
||||||
|
data class Delete(val tag: String) : Dialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
|
||||||
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom adapter for repos.
|
|
||||||
*
|
|
||||||
* @param controller The containing controller.
|
|
||||||
*/
|
|
||||||
class RepoAdapter(controller: RepoController) :
|
|
||||||
FlexibleAdapter<RepoItem>(null, controller, true) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the active selections from the list and the model.
|
|
||||||
*/
|
|
||||||
override fun clearSelection() {
|
|
||||||
super.clearSelection()
|
|
||||||
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection of the given position.
|
|
||||||
*
|
|
||||||
* @param position The position to toggle.
|
|
||||||
*/
|
|
||||||
override fun toggleSelection(position: Int) {
|
|
||||||
super.toggleSelection(position)
|
|
||||||
getItem(position)?.isSelected = isSelected(position)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,313 +1,21 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
package eu.kanade.tachiyomi.ui.category.repos
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.Menu
|
import eu.kanade.presentation.category.SourceRepoScreen
|
||||||
import android.view.MenuItem
|
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dev.chrisbanes.insetter.applyInsetter
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.SelectableAdapter
|
|
||||||
import eu.davidea.flexibleadapter.helpers.UndoHelper
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.FabController
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
|
||||||
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller to manage the categories for the users' library.
|
* Controller to manage the categories for the users' library.
|
||||||
*/
|
*/
|
||||||
class RepoController :
|
class RepoController : FullComposeController<RepoPresenter>() {
|
||||||
NucleusController<CategoriesControllerBinding, RepoPresenter>(),
|
|
||||||
FabController,
|
|
||||||
ActionMode.Callback,
|
|
||||||
FlexibleAdapter.OnItemClickListener,
|
|
||||||
FlexibleAdapter.OnItemLongClickListener,
|
|
||||||
RepoCreateDialog.Listener,
|
|
||||||
UndoHelper.OnActionListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object used to show ActionMode toolbar.
|
|
||||||
*/
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing repo items.
|
|
||||||
*/
|
|
||||||
private var adapter: RepoAdapter? = null
|
|
||||||
|
|
||||||
private var actionFab: ExtendedFloatingActionButton? = null
|
|
||||||
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo helper used for restoring a deleted repo.
|
|
||||||
*/
|
|
||||||
private var undoHelper: UndoHelper? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the presenter for this controller. Not to be manually called.
|
|
||||||
*/
|
|
||||||
override fun createPresenter() = RepoPresenter()
|
override fun createPresenter() = RepoPresenter()
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Returns the toolbar title to show when this controller is attached.
|
override fun ComposeContent() {
|
||||||
*/
|
SourceRepoScreen(
|
||||||
override fun getTitle(): String? {
|
presenter = presenter,
|
||||||
return resources?.getString(R.string.action_edit_repos)
|
navigateUp = router::popCurrentController,
|
||||||
}
|
)
|
||||||
|
|
||||||
override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after view inflation. Used to initialize the view.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onViewCreated(view: View) {
|
|
||||||
super.onViewCreated(view)
|
|
||||||
|
|
||||||
binding.recycler.applyInsetter {
|
|
||||||
type(navigationBars = true) {
|
|
||||||
padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = RepoAdapter(this@RepoController)
|
|
||||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
|
||||||
binding.recycler.setHasFixedSize(true)
|
|
||||||
binding.recycler.adapter = adapter
|
|
||||||
adapter?.isPermanentDelete = false
|
|
||||||
|
|
||||||
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
actionFab = fab
|
|
||||||
fab.setText(R.string.action_add)
|
|
||||||
fab.setIconResource(R.drawable.ic_add_24dp)
|
|
||||||
fab.setOnClickListener {
|
|
||||||
RepoCreateDialog(this@RepoController).showDialog(router, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
fab.setOnClickListener(null)
|
|
||||||
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
|
|
||||||
actionFab = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the view is being destroyed. Used to release references and remove callbacks.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onDestroyView(view: View) {
|
|
||||||
// Manually call callback to delete repos if required
|
|
||||||
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
|
|
||||||
undoHelper = null
|
|
||||||
actionMode = null
|
|
||||||
adapter = null
|
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when the repos are updated.
|
|
||||||
*
|
|
||||||
* @param repos The new list of repos to display.
|
|
||||||
*/
|
|
||||||
fun setRepos(repos: List<RepoItem>) {
|
|
||||||
actionMode?.finish()
|
|
||||||
adapter?.updateDataSet(repos)
|
|
||||||
if (repos.isNotEmpty()) {
|
|
||||||
binding.emptyView.hide()
|
|
||||||
val selected = repos.filter { it.isSelected }
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
selected.forEach { onItemLongClick(repos.indexOf(it)) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.emptyView.show(R.string.information_empty_repos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when action mode is first created. The menu supplied will be used to generate action
|
|
||||||
* buttons for the action mode.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being created.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the action mode should be created, false if entering this mode should be
|
|
||||||
* aborted.
|
|
||||||
*/
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
// Inflate menu.
|
|
||||||
mode.menuInflater.inflate(R.menu.category_selection, menu)
|
|
||||||
// Enable adapter multi selection.
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.MULTI
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to refresh an action mode's action menu whenever it is invalidated.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being prepared.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the menu or action mode was updated, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
val count = adapter.selectedItemCount
|
|
||||||
mode.title = count.toString()
|
|
||||||
|
|
||||||
// Show edit button only when one item is selected
|
|
||||||
val editItem = mode.menu.findItem(R.id.action_edit)
|
|
||||||
editItem.isVisible = false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to report a user click on an action button.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode.
|
|
||||||
* @param item The item that was clicked.
|
|
||||||
* @return true if this callback handled the event, false if the standard MenuItem invocation
|
|
||||||
* should continue.
|
|
||||||
*/
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_delete -> {
|
|
||||||
undoHelper = UndoHelper(adapter, this)
|
|
||||||
undoHelper?.start(
|
|
||||||
adapter.selectedPositions,
|
|
||||||
(activity as? MainActivity)?.binding?.rootCoordinator!!,
|
|
||||||
R.string.snack_repo_deleted,
|
|
||||||
R.string.action_undo,
|
|
||||||
3000,
|
|
||||||
)
|
|
||||||
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an action mode is about to be exited and destroyed.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode being destroyed.
|
|
||||||
*/
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
// Reset adapter to single selection
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.IDLE
|
|
||||||
adapter?.clearSelection()
|
|
||||||
actionMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
* @return true if this click should enable selection mode.
|
|
||||||
*/
|
|
||||||
override fun onItemClick(view: View, position: Int): Boolean {
|
|
||||||
// Check if action mode is initialized and selected item exist.
|
|
||||||
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
|
|
||||||
toggleSelection(position)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is long clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
*/
|
|
||||||
override fun onItemLongClick(position: Int) {
|
|
||||||
val activity = activity as? AppCompatActivity ?: return
|
|
||||||
|
|
||||||
// Check if action mode is initialized.
|
|
||||||
if (actionMode == null) {
|
|
||||||
// Initialize action mode
|
|
||||||
actionMode = activity.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set item as selected
|
|
||||||
toggleSelection(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the selection state of an item.
|
|
||||||
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
|
|
||||||
*
|
|
||||||
* @param position The position of the item to toggle.
|
|
||||||
*/
|
|
||||||
private fun toggleSelection(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
|
|
||||||
// Mark the position selected
|
|
||||||
adapter.toggleSelection(position)
|
|
||||||
|
|
||||||
if (adapter.selectedItemCount == 0) {
|
|
||||||
actionMode?.finish()
|
|
||||||
} else {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the undo action is clicked in the snackbar.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
*/
|
|
||||||
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
|
|
||||||
adapter?.restoreDeletedItems()
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the time to restore the items expires.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
* @param event The event that triggered the action
|
|
||||||
*/
|
|
||||||
override fun onActionConfirmed(action: Int, event: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
presenter.deleteRepos(adapter.deletedItems.map { it.repo })
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new repo with the given name.
|
|
||||||
*
|
|
||||||
* @param name The name of the new repo.
|
|
||||||
*/
|
|
||||||
override fun createRepo(name: String) {
|
|
||||||
presenter.createRepo(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a repo already exists.
|
|
||||||
*/
|
|
||||||
fun onRepoExistsError() {
|
|
||||||
activity?.toast(R.string.error_repo_exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a invalid repo is made
|
|
||||||
*/
|
|
||||||
fun onRepoInvalidNameError() {
|
|
||||||
activity?.toast(R.string.invalid_repo_name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to create a new repo for the library.
|
|
||||||
*/
|
|
||||||
class RepoCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : RepoCreateDialog.Listener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new repo. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T) : this() {
|
|
||||||
targetController = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_add_repo)
|
|
||||||
.setMessage(R.string.action_add_repo_message)
|
|
||||||
.setTextInput(
|
|
||||||
hint = resources?.getString(R.string.name),
|
|
||||||
prefill = currentName,
|
|
||||||
) { input ->
|
|
||||||
currentName = input
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
(targetController as? Listener)?.createRepo(currentName)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun createRepo(name: String)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import eu.davidea.viewholders.FlexibleViewHolder
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
|
|
||||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holder used to display repo items.
|
|
||||||
*
|
|
||||||
* @param view The view used by repo items.
|
|
||||||
* @param adapter The adapter containing this holder.
|
|
||||||
*/
|
|
||||||
class RepoHolder(view: View, val adapter: RepoAdapter) : FlexibleViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = CategoriesItemBinding.bind(view)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds this holder with the given category.
|
|
||||||
*
|
|
||||||
* @param category The category to bind.
|
|
||||||
*/
|
|
||||||
fun bind(category: String) {
|
|
||||||
binding.innerContainer.minimumHeight = 60.dpToPx
|
|
||||||
|
|
||||||
// Set capitalized title.
|
|
||||||
binding.title.text = category
|
|
||||||
binding.reorder.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,62 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repo item for a recycler view.
|
|
||||||
*/
|
|
||||||
class RepoItem(val repo: String) : AbstractFlexibleItem<RepoHolder>() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this item is currently selected.
|
|
||||||
*/
|
|
||||||
var isSelected = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the layout resource for this item.
|
|
||||||
*/
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.categories_item
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new view holder for this item.
|
|
||||||
*
|
|
||||||
* @param view The view of this item.
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
*/
|
|
||||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): RepoHolder {
|
|
||||||
return RepoHolder(view, adapter as RepoAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the given view holder with this item.
|
|
||||||
*
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
* @param holder The holder to bind.
|
|
||||||
* @param position The position of this item in the adapter.
|
|
||||||
* @param payloads List of partial changes.
|
|
||||||
*/
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: RepoHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<Any?>?,
|
|
||||||
) {
|
|
||||||
holder.bind(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return repo.hashCode()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.repos
|
package eu.kanade.tachiyomi.ui.category.repos
|
||||||
|
|
||||||
import android.os.Bundle
|
import androidx.compose.runtime.getValue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import eu.kanade.domain.source.interactor.CreateSourceRepo
|
||||||
|
import eu.kanade.domain.source.interactor.DeleteSourceRepos
|
||||||
|
import eu.kanade.domain.source.interactor.GetSourceRepos
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.channels.Channel
|
||||||
import rx.Observable
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
@ -14,30 +17,17 @@ import uy.kohesive.injekt.api.get
|
|||||||
* Presenter of [RepoController]. Used to manage the repos for the extensions.
|
* Presenter of [RepoController]. Used to manage the repos for the extensions.
|
||||||
*/
|
*/
|
||||||
class RepoPresenter(
|
class RepoPresenter(
|
||||||
private val preferences: PreferencesHelper = Injekt.get(),
|
private val getSourceRepos: GetSourceRepos = Injekt.get(),
|
||||||
|
private val createSourceRepo: CreateSourceRepo = Injekt.get(),
|
||||||
|
private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(),
|
||||||
) : BasePresenter<RepoController>() {
|
) : BasePresenter<RepoController>() {
|
||||||
/**
|
|
||||||
* List containing repos.
|
|
||||||
*/
|
|
||||||
private var repos: List<String> = emptyList()
|
|
||||||
|
|
||||||
/**
|
var dialog: Dialog? by mutableStateOf(null)
|
||||||
* Called when the presenter is created.
|
|
||||||
*
|
|
||||||
* @param savedState The saved state of this presenter.
|
|
||||||
*/
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
|
||||||
super.onCreate(savedState)
|
|
||||||
|
|
||||||
preferences.extensionRepos().asFlow().onEach { repos ->
|
val repos = getSourceRepos.subscribe()
|
||||||
this.repos = repos.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it }))
|
|
||||||
|
|
||||||
Observable.just(this.repos)
|
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||||
.map { it.map(::RepoItem) }
|
val events = _events.consumeAsFlow()
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribeLatestCache(RepoController::setRepos)
|
|
||||||
}.launchIn(presenterScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and adds a new repo to the database.
|
* Creates and adds a new repo to the database.
|
||||||
@ -45,19 +35,13 @@ class RepoPresenter(
|
|||||||
* @param name The name of the repo to create.
|
* @param name The name of the repo to create.
|
||||||
*/
|
*/
|
||||||
fun createRepo(name: String) {
|
fun createRepo(name: String) {
|
||||||
// Do not allow duplicate repos.
|
presenterScope.launchIO {
|
||||||
if (repoExists(name)) {
|
when (createSourceRepo.await(name)) {
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onRepoExistsError() })
|
is CreateSourceRepo.Result.RepoExists -> _events.send(Event.RepoExists)
|
||||||
return
|
is CreateSourceRepo.Result.InvalidName -> _events.send(Event.InvalidName)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not allow invalid formats
|
|
||||||
if (!name.matches(repoRegex)) {
|
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onRepoInvalidNameError() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
preferences.extensionRepos().set((repos + name).toSet())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,19 +50,19 @@ class RepoPresenter(
|
|||||||
* @param repos The list of repos to delete.
|
* @param repos The list of repos to delete.
|
||||||
*/
|
*/
|
||||||
fun deleteRepos(repos: List<String>) {
|
fun deleteRepos(repos: List<String>) {
|
||||||
preferences.extensionRepos().set(
|
presenterScope.launchIO {
|
||||||
this.repos.filterNot { it in repos }.toSet(),
|
deleteSourceRepos.await(repos)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
sealed class Event {
|
||||||
* Returns true if a repo with the given name already exists.
|
object RepoExists : Event()
|
||||||
*/
|
object InvalidName : Event()
|
||||||
private fun repoExists(name: String): Boolean {
|
object InternalError : Event()
|
||||||
return repos.any { it.equals(name, true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
sealed class Dialog {
|
||||||
val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex()
|
object Create : Dialog()
|
||||||
|
data class Delete(val repo: String) : Dialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
|
|
||||||
class ChangeSourceCategoriesDialog<T>(bundle: Bundle? = null) :
|
|
||||||
DialogController(bundle) where T : Controller, T : ChangeSourceCategoriesDialog.Listener {
|
|
||||||
|
|
||||||
private var source: Source? = null
|
|
||||||
|
|
||||||
private var categories = emptyArray<String>()
|
|
||||||
|
|
||||||
private var selection = booleanArrayOf()
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
target: T,
|
|
||||||
source: Source,
|
|
||||||
categories: Array<String>,
|
|
||||||
selection: BooleanArray,
|
|
||||||
) : this() {
|
|
||||||
this.source = source
|
|
||||||
this.categories = categories
|
|
||||||
this.selection = selection
|
|
||||||
targetController = target
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_move_category)
|
|
||||||
.setMultiChoiceItems(
|
|
||||||
categories,
|
|
||||||
selection,
|
|
||||||
) { _, which, selected ->
|
|
||||||
selection[which] = selected
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
val newCategories = categories.filterIndexed { index, s ->
|
|
||||||
selection[index]
|
|
||||||
}
|
|
||||||
(targetController as? Listener)?.updateCategoriesForSource(source!!, newCategories)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun updateCategoriesForSource(source: Source, categories: List<String>)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom adapter for categories.
|
|
||||||
*
|
|
||||||
* @param controller The containing controller.
|
|
||||||
*/
|
|
||||||
class SourceCategoryAdapter(controller: SourceCategoryController) :
|
|
||||||
FlexibleAdapter<SourceCategoryItem>(null, controller, true) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the active selections from the list and the model.
|
|
||||||
*/
|
|
||||||
override fun clearSelection() {
|
|
||||||
super.clearSelection()
|
|
||||||
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection of the given position.
|
|
||||||
*
|
|
||||||
* @param position The position to toggle.
|
|
||||||
*/
|
|
||||||
override fun toggleSelection(position: Int) {
|
|
||||||
super.toggleSelection(position)
|
|
||||||
getItem(position)?.isSelected = isSelected(position)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,343 +1,21 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
package eu.kanade.tachiyomi.ui.category.sources
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.Menu
|
import eu.kanade.presentation.category.SourceCategoryScreen
|
||||||
import android.view.MenuItem
|
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dev.chrisbanes.insetter.applyInsetter
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.SelectableAdapter
|
|
||||||
import eu.davidea.flexibleadapter.helpers.UndoHelper
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.FabController
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
|
||||||
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller to manage the categories for the users' library.
|
* Controller to manage the categories for the users' library.
|
||||||
*/
|
*/
|
||||||
class SourceCategoryController :
|
class SourceCategoryController : FullComposeController<SourceCategoryPresenter>() {
|
||||||
NucleusController<CategoriesControllerBinding, SourceCategoryPresenter>(),
|
|
||||||
FabController,
|
|
||||||
ActionMode.Callback,
|
|
||||||
FlexibleAdapter.OnItemClickListener,
|
|
||||||
FlexibleAdapter.OnItemLongClickListener,
|
|
||||||
SourceCategoryCreateDialog.Listener,
|
|
||||||
SourceCategoryRenameDialog.Listener,
|
|
||||||
UndoHelper.OnActionListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object used to show ActionMode toolbar.
|
|
||||||
*/
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing category items.
|
|
||||||
*/
|
|
||||||
private var adapter: SourceCategoryAdapter? = null
|
|
||||||
|
|
||||||
private var actionFab: ExtendedFloatingActionButton? = null
|
|
||||||
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo helper used for restoring a deleted category.
|
|
||||||
*/
|
|
||||||
private var undoHelper: UndoHelper? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the presenter for this controller. Not to be manually called.
|
|
||||||
*/
|
|
||||||
override fun createPresenter() = SourceCategoryPresenter()
|
override fun createPresenter() = SourceCategoryPresenter()
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Returns the toolbar title to show when this controller is attached.
|
override fun ComposeContent() {
|
||||||
*/
|
SourceCategoryScreen(
|
||||||
override fun getTitle(): String? {
|
presenter = presenter,
|
||||||
return resources?.getString(R.string.action_edit_categories)
|
navigateUp = router::popCurrentController,
|
||||||
}
|
)
|
||||||
|
|
||||||
override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after view inflation. Used to initialize the view.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onViewCreated(view: View) {
|
|
||||||
super.onViewCreated(view)
|
|
||||||
|
|
||||||
binding.recycler.applyInsetter {
|
|
||||||
type(navigationBars = true) {
|
|
||||||
padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = SourceCategoryAdapter(this@SourceCategoryController)
|
|
||||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
|
||||||
binding.recycler.setHasFixedSize(true)
|
|
||||||
binding.recycler.adapter = adapter
|
|
||||||
adapter?.isPermanentDelete = false
|
|
||||||
|
|
||||||
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
actionFab = fab
|
|
||||||
fab.setText(R.string.action_add)
|
|
||||||
fab.setIconResource(R.drawable.ic_add_24dp)
|
|
||||||
fab.setOnClickListener {
|
|
||||||
SourceCategoryCreateDialog(this@SourceCategoryController).showDialog(router, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
|
|
||||||
fab.setOnClickListener(null)
|
|
||||||
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
|
|
||||||
actionFab = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the view is being destroyed. Used to release references and remove callbacks.
|
|
||||||
*
|
|
||||||
* @param view The view of this controller.
|
|
||||||
*/
|
|
||||||
override fun onDestroyView(view: View) {
|
|
||||||
// Manually call callback to delete categories if required
|
|
||||||
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
|
|
||||||
undoHelper = null
|
|
||||||
actionMode = null
|
|
||||||
adapter = null
|
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when the categories are updated.
|
|
||||||
*
|
|
||||||
* @param categories The new list of categories to display.
|
|
||||||
*/
|
|
||||||
fun setCategories(categories: List<SourceCategoryItem>) {
|
|
||||||
actionMode?.finish()
|
|
||||||
adapter?.updateDataSet(categories)
|
|
||||||
if (categories.isNotEmpty()) {
|
|
||||||
binding.emptyView.hide()
|
|
||||||
val selected = categories.filter { it.isSelected }
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
selected.forEach { onItemLongClick(categories.indexOf(it)) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.emptyView.show(R.string.information_empty_category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when action mode is first created. The menu supplied will be used to generate action
|
|
||||||
* buttons for the action mode.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being created.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the action mode should be created, false if entering this mode should be
|
|
||||||
* aborted.
|
|
||||||
*/
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
// Inflate menu.
|
|
||||||
mode.menuInflater.inflate(R.menu.category_selection, menu)
|
|
||||||
// Enable adapter multi selection.
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.MULTI
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to refresh an action mode's action menu whenever it is invalidated.
|
|
||||||
*
|
|
||||||
* @param mode ActionMode being prepared.
|
|
||||||
* @param menu Menu used to populate action buttons.
|
|
||||||
* @return true if the menu or action mode was updated, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
val count = adapter.selectedItemCount
|
|
||||||
mode.title = count.toString()
|
|
||||||
|
|
||||||
// Show edit button only when one item is selected
|
|
||||||
val editItem = mode.menu.findItem(R.id.action_edit)
|
|
||||||
editItem.isVisible = count == 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to report a user click on an action button.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode.
|
|
||||||
* @param item The item that was clicked.
|
|
||||||
* @return true if this callback handled the event, false if the standard MenuItem invocation
|
|
||||||
* should continue.
|
|
||||||
*/
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
val adapter = adapter ?: return false
|
|
||||||
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_delete -> {
|
|
||||||
undoHelper = UndoHelper(adapter, this)
|
|
||||||
undoHelper?.start(
|
|
||||||
adapter.selectedPositions,
|
|
||||||
(activity as? MainActivity)?.binding?.rootCoordinator!!,
|
|
||||||
R.string.snack_categories_deleted,
|
|
||||||
R.string.action_undo,
|
|
||||||
3000,
|
|
||||||
)
|
|
||||||
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
R.id.action_edit -> {
|
|
||||||
// Edit selected category
|
|
||||||
if (adapter.selectedItemCount == 1) {
|
|
||||||
val position = adapter.selectedPositions.first()
|
|
||||||
val category = adapter.getItem(position)?.category
|
|
||||||
if (category != null) {
|
|
||||||
editCategory(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an action mode is about to be exited and destroyed.
|
|
||||||
*
|
|
||||||
* @param mode The current ActionMode being destroyed.
|
|
||||||
*/
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
// Reset adapter to single selection
|
|
||||||
adapter?.mode = SelectableAdapter.Mode.IDLE
|
|
||||||
adapter?.clearSelection()
|
|
||||||
actionMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
* @return true if this click should enable selection mode.
|
|
||||||
*/
|
|
||||||
override fun onItemClick(view: View, position: Int): Boolean {
|
|
||||||
// Check if action mode is initialized and selected item exist.
|
|
||||||
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
|
|
||||||
toggleSelection(position)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an item in the list is long clicked.
|
|
||||||
*
|
|
||||||
* @param position The position of the clicked item.
|
|
||||||
*/
|
|
||||||
override fun onItemLongClick(position: Int) {
|
|
||||||
val activity = activity as? AppCompatActivity ?: return
|
|
||||||
|
|
||||||
// Check if action mode is initialized.
|
|
||||||
if (actionMode == null) {
|
|
||||||
// Initialize action mode
|
|
||||||
actionMode = activity.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set item as selected
|
|
||||||
toggleSelection(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the selection state of an item.
|
|
||||||
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
|
|
||||||
*
|
|
||||||
* @param position The position of the item to toggle.
|
|
||||||
*/
|
|
||||||
private fun toggleSelection(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
|
|
||||||
// Mark the position selected
|
|
||||||
adapter.toggleSelection(position)
|
|
||||||
|
|
||||||
if (adapter.selectedItemCount == 0) {
|
|
||||||
actionMode?.finish()
|
|
||||||
} else {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the undo action is clicked in the snackbar.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
*/
|
|
||||||
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
|
|
||||||
adapter?.restoreDeletedItems()
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the time to restore the items expires.
|
|
||||||
*
|
|
||||||
* @param action The action performed.
|
|
||||||
* @param event The event that triggered the action
|
|
||||||
*/
|
|
||||||
override fun onActionConfirmed(action: Int, event: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
presenter.deleteCategories(adapter.deletedItems.map { it.category })
|
|
||||||
undoHelper = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a dialog to let the user change the category name.
|
|
||||||
*
|
|
||||||
* @param category The category to be edited.
|
|
||||||
*/
|
|
||||||
private fun editCategory(category: String) {
|
|
||||||
SourceCategoryRenameDialog(this, category).showDialog(router)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames the given category with the given name.
|
|
||||||
*
|
|
||||||
* @param category The category to rename.
|
|
||||||
* @param name The new name of the category.
|
|
||||||
*/
|
|
||||||
override fun renameCategory(category: String, name: String) {
|
|
||||||
presenter.renameCategory(category, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new category with the given name.
|
|
||||||
*
|
|
||||||
* @param name The name of the new category.
|
|
||||||
*/
|
|
||||||
override fun createCategory(name: String) {
|
|
||||||
presenter.createCategory(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a category with the given name already exists.
|
|
||||||
*/
|
|
||||||
fun onCategoryExistsError() {
|
|
||||||
activity?.toast(R.string.error_category_exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the presenter when a invalid category name is made
|
|
||||||
*/
|
|
||||||
fun onCategoryInvalidNameError() {
|
|
||||||
activity?.toast(R.string.invalid_category_name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,51 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to create a new category for the library.
|
|
||||||
*/
|
|
||||||
class SourceCategoryCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : SourceCategoryCreateDialog.Listener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new category. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T) : this() {
|
|
||||||
targetController = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_add_category)
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setTextInput(
|
|
||||||
hint = resources?.getString(R.string.name),
|
|
||||||
prefill = currentName,
|
|
||||||
) { input ->
|
|
||||||
currentName = input
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
(targetController as? Listener)?.createCategory(currentName)
|
|
||||||
}
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun createCategory(name: String)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import eu.davidea.viewholders.FlexibleViewHolder
|
|
||||||
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
|
|
||||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holder used to display category items.
|
|
||||||
*
|
|
||||||
* @param view The view used by category items.
|
|
||||||
* @param adapter The adapter containing this holder.
|
|
||||||
*/
|
|
||||||
class SourceCategoryHolder(view: View, val adapter: SourceCategoryAdapter) : FlexibleViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = CategoriesItemBinding.bind(view)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds this holder with the given category.
|
|
||||||
*
|
|
||||||
* @param category The category to bind.
|
|
||||||
*/
|
|
||||||
fun bind(category: String) {
|
|
||||||
binding.innerContainer.minimumHeight = 60.dpToPx
|
|
||||||
|
|
||||||
// Set capitalized title.
|
|
||||||
binding.title.text = category
|
|
||||||
binding.reorder.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,62 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category item for a recycler view.
|
|
||||||
*/
|
|
||||||
class SourceCategoryItem(val category: String) : AbstractFlexibleItem<SourceCategoryHolder>() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this item is currently selected.
|
|
||||||
*/
|
|
||||||
var isSelected = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the layout resource for this item.
|
|
||||||
*/
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.categories_item
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new view holder for this item.
|
|
||||||
*
|
|
||||||
* @param view The view of this item.
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
*/
|
|
||||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceCategoryHolder {
|
|
||||||
return SourceCategoryHolder(view, adapter as SourceCategoryAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the given view holder with this item.
|
|
||||||
*
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
* @param holder The holder to bind.
|
|
||||||
* @param position The position of this item in the adapter.
|
|
||||||
* @param payloads List of partial changes.
|
|
||||||
*/
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: SourceCategoryHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<Any?>?,
|
|
||||||
) {
|
|
||||||
holder.bind(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return category.hashCode()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,44 +1,35 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
package eu.kanade.tachiyomi.ui.category.sources
|
||||||
|
|
||||||
import android.os.Bundle
|
import androidx.compose.runtime.getValue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import eu.kanade.domain.source.interactor.CreateSourceCategory
|
||||||
|
import eu.kanade.domain.source.interactor.DeleteSourceCategory
|
||||||
|
import eu.kanade.domain.source.interactor.GetSourceCategories
|
||||||
|
import eu.kanade.domain.source.interactor.RenameSourceCategory
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.channels.Channel
|
||||||
import rx.Observable
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenter of [SourceCategoryController]. Used to manage the categories of the library.
|
* Presenter of [SourceCategoryController]. Used to manage the categories of the library.
|
||||||
*/
|
*/
|
||||||
class SourceCategoryPresenter : BasePresenter<SourceCategoryController>() {
|
class SourceCategoryPresenter(
|
||||||
|
private val getSourceCategories: GetSourceCategories = Injekt.get(),
|
||||||
|
private val createSourceCategory: CreateSourceCategory = Injekt.get(),
|
||||||
|
private val renameSourceCategory: RenameSourceCategory = Injekt.get(),
|
||||||
|
private val deleteSourceCategory: DeleteSourceCategory = Injekt.get(),
|
||||||
|
) : BasePresenter<SourceCategoryController>() {
|
||||||
|
|
||||||
/**
|
var dialog: Dialog? by mutableStateOf(null)
|
||||||
* List containing categories.
|
|
||||||
*/
|
|
||||||
private var categories: List<String> = emptyList()
|
|
||||||
|
|
||||||
val preferences: PreferencesHelper = Injekt.get()
|
val categories = getSourceCategories.subscribe()
|
||||||
|
|
||||||
/**
|
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||||
* Called when the presenter is created.
|
val events = _events.consumeAsFlow()
|
||||||
*
|
|
||||||
* @param savedState The saved state of this presenter.
|
|
||||||
*/
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
|
||||||
super.onCreate(savedState)
|
|
||||||
|
|
||||||
preferences.sourcesTabCategories().asFlow().onEach { categories ->
|
|
||||||
this.categories = categories.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it }))
|
|
||||||
|
|
||||||
Observable.just(this.categories)
|
|
||||||
.map { it.map(::SourceCategoryItem) }
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribeLatestCache(SourceCategoryController::setCategories)
|
|
||||||
}.launchIn(presenterScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and adds a new category to the database.
|
* Creates and adds a new category to the database.
|
||||||
@ -46,22 +37,13 @@ class SourceCategoryPresenter : BasePresenter<SourceCategoryController>() {
|
|||||||
* @param name The name of the category to create.
|
* @param name The name of the category to create.
|
||||||
*/
|
*/
|
||||||
fun createCategory(name: String) {
|
fun createCategory(name: String) {
|
||||||
// Do not allow duplicate categories.
|
presenterScope.launchIO {
|
||||||
if (categoryExists(name)) {
|
when (createSourceCategory.await(name)) {
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
|
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
|
||||||
return
|
is CreateSourceCategory.Result.InvalidName -> _events.send(Event.InvalidName)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.contains("|")) {
|
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryInvalidNameError() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create category.
|
|
||||||
val newCategories = categories.toMutableList()
|
|
||||||
newCategories += name
|
|
||||||
|
|
||||||
preferences.sourcesTabCategories().set(newCategories.toSet())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,56 +51,37 @@ class SourceCategoryPresenter : BasePresenter<SourceCategoryController>() {
|
|||||||
*
|
*
|
||||||
* @param categories The list of categories to delete.
|
* @param categories The list of categories to delete.
|
||||||
*/
|
*/
|
||||||
fun deleteCategories(categories: List<String>) {
|
fun deleteCategory(categories: String) {
|
||||||
var sources = preferences.sourcesTabSourcesInCategories().get().toList()
|
presenterScope.launchIO {
|
||||||
|
deleteSourceCategory.await(categories)
|
||||||
sources = sources.map { it.split("|") }.filterNot { it[1] in categories }.map { it[0] + "|" + it[1] }
|
}
|
||||||
|
|
||||||
preferences.sourcesTabSourcesInCategories().set(sources.toSet())
|
|
||||||
preferences.sourcesTabCategories().set(
|
|
||||||
this.categories.filterNot { it in categories }.toSet(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renames a category.
|
* Renames a category.
|
||||||
*
|
*
|
||||||
* @param category The category to rename.
|
* @param categoryOld The category to rename.
|
||||||
* @param name The new name of the category.
|
* @param categoryNew The new name of the category.
|
||||||
*/
|
*/
|
||||||
fun renameCategory(categoryOld: String, categoryNew: String) {
|
fun renameCategory(categoryOld: String, categoryNew: String) {
|
||||||
// Do not allow duplicate categories.
|
presenterScope.launchIO {
|
||||||
if (categoryExists(categoryNew)) {
|
when (renameSourceCategory.await(categoryOld, categoryNew)) {
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
|
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
|
||||||
return
|
is CreateSourceCategory.Result.InvalidName -> _events.send(Event.InvalidName)
|
||||||
}
|
else -> {}
|
||||||
|
|
||||||
if (categoryNew.contains("|")) {
|
|
||||||
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryInvalidNameError() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val newCategories = categories.filterNot { it in categoryOld }.toMutableList()
|
|
||||||
newCategories += categoryNew
|
|
||||||
|
|
||||||
var sources = preferences.sourcesTabSourcesInCategories().get().toList()
|
|
||||||
|
|
||||||
sources = sources.map { it.split("|").toMutableList() }
|
|
||||||
.map {
|
|
||||||
if (it[1] == categoryOld) {
|
|
||||||
it[1] = categoryNew
|
|
||||||
}
|
|
||||||
it[0] + "|" + it[1]
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
preferences.sourcesTabSourcesInCategories().set(sources.toSet())
|
|
||||||
preferences.sourcesTabCategories().set(newCategories.sorted().toSet())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
sealed class Event {
|
||||||
* Returns true if a category with the given name already exists.
|
object CategoryExists : Event()
|
||||||
*/
|
object InvalidName : Event()
|
||||||
private fun categoryExists(name: String): Boolean {
|
object InternalError : Event()
|
||||||
return categories.any { it.equals(name, true) }
|
}
|
||||||
|
|
||||||
|
sealed class Dialog {
|
||||||
|
object Create : Dialog()
|
||||||
|
data class Rename(val category: String) : Dialog()
|
||||||
|
data class Delete(val category: String) : Dialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category.sources
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.bluelinelabs.conductor.Controller
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
|
||||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog to rename an existing category of the library.
|
|
||||||
*/
|
|
||||||
class SourceCategoryRenameDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
|
||||||
where T : Controller, T : SourceCategoryRenameDialog.Listener {
|
|
||||||
|
|
||||||
private var category: String? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the new category. Value updated with each input from the user.
|
|
||||||
*/
|
|
||||||
private var currentName = ""
|
|
||||||
|
|
||||||
constructor(target: T, category: String) : this() {
|
|
||||||
targetController = target
|
|
||||||
this.category = category
|
|
||||||
currentName = category
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when creating the dialog for this controller.
|
|
||||||
*
|
|
||||||
* @param savedViewState The saved state of this dialog.
|
|
||||||
* @return a new dialog instance.
|
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
|
||||||
return MaterialAlertDialogBuilder(activity!!)
|
|
||||||
.setTitle(R.string.action_rename_category)
|
|
||||||
.setTextInput(
|
|
||||||
hint = resources?.getString(R.string.name),
|
|
||||||
prefill = currentName,
|
|
||||||
) { input ->
|
|
||||||
currentName = input
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ -> onPositive() }
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to save this Controller's state in the event that its host Activity is destroyed.
|
|
||||||
*
|
|
||||||
* @param outState The Bundle into which data should be saved
|
|
||||||
*/
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
outState.putSerializable(CATEGORY_KEY, category)
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores data that was saved in the [onSaveInstanceState] method.
|
|
||||||
*
|
|
||||||
* @param savedInstanceState The bundle that has data to be restored
|
|
||||||
*/
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
category = savedInstanceState.getSerializable(CATEGORY_KEY) as? String
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the positive button of the dialog is clicked.
|
|
||||||
*/
|
|
||||||
private fun onPositive() {
|
|
||||||
val target = targetController as? Listener ?: return
|
|
||||||
val category = category ?: return
|
|
||||||
|
|
||||||
target.renameCategory(category, currentName)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Listener {
|
|
||||||
fun renameCategory(category: String, name: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val CATEGORY_KEY = "CategoryRenameDialog.category"
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,7 +11,6 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryAdapter
|
|
||||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
import exh.log.xLogW
|
import exh.log.xLogW
|
||||||
import exh.metadata.sql.models.SearchTag
|
import exh.metadata.sql.models.SearchTag
|
||||||
@ -70,10 +69,6 @@ class LibraryCategoryAdapter(view: LibraryCategoryView, val controller: LibraryC
|
|||||||
*/
|
*/
|
||||||
private var mangas: List<LibraryItem> = emptyList()
|
private var mangas: List<LibraryItem> = emptyList()
|
||||||
|
|
||||||
// SY -->
|
|
||||||
val onItemReleaseListener: CategoryAdapter.OnItemReleaseListener = view
|
|
||||||
// SY <--
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a list of manga in the adapter.
|
* Sets a list of manga in the adapter.
|
||||||
*
|
*
|
||||||
|
@ -20,7 +20,6 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
|||||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
|
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryAdapter
|
|
||||||
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
|
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
|
||||||
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
|
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
@ -53,8 +52,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
|
|||||||
FlexibleAdapter.OnItemClickListener,
|
FlexibleAdapter.OnItemClickListener,
|
||||||
FlexibleAdapter.OnItemLongClickListener,
|
FlexibleAdapter.OnItemLongClickListener,
|
||||||
// SY -->
|
// SY -->
|
||||||
FlexibleAdapter.OnItemMoveListener,
|
FlexibleAdapter.OnItemMoveListener {
|
||||||
CategoryAdapter.OnItemReleaseListener {
|
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
private val scope = MainScope()
|
private val scope = MainScope()
|
||||||
@ -411,10 +409,6 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean {
|
override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean {
|
||||||
if (adapter.isSelected(fromPosition)) toggleSelection(fromPosition)
|
if (adapter.isSelected(fromPosition)) toggleSelection(fromPosition)
|
||||||
return true
|
return true
|
||||||
|
@ -28,16 +28,6 @@ abstract class LibraryHolder<VB : ViewBinding>(
|
|||||||
abstract fun onSetValues(item: LibraryItem)
|
abstract fun onSetValues(item: LibraryItem)
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
/**
|
|
||||||
* Called when an item is released.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
super.onItemReleased(position)
|
|
||||||
(adapter as? LibraryCategoryAdapter)?.onItemReleaseListener?.onItemReleased(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLongClick(view: View?): Boolean {
|
override fun onLongClick(view: View?): Boolean {
|
||||||
return if (adapter.isLongPressDragEnabled) {
|
return if (adapter.isLongPressDragEnabled) {
|
||||||
super.onLongClick(view)
|
super.onLongClick(view)
|
||||||
|
@ -123,8 +123,8 @@ class GalleryAdder(
|
|||||||
insertManga.await(
|
insertManga.await(
|
||||||
Manga.create().copy(
|
Manga.create().copy(
|
||||||
source = source.id,
|
source = source.id,
|
||||||
url = cleanedMangaUrl
|
url = cleanedMangaUrl,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
getManga.await(cleanedMangaUrl, source.id)!!
|
getManga.await(cleanedMangaUrl, source.id)!!
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,8 @@ package exh.favorites
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import eu.kanade.domain.category.interactor.CreateCategoryWithName
|
||||||
import eu.kanade.domain.category.interactor.GetCategories
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
import eu.kanade.domain.category.interactor.InsertCategory
|
|
||||||
import eu.kanade.domain.category.interactor.SetMangaCategories
|
import eu.kanade.domain.category.interactor.SetMangaCategories
|
||||||
import eu.kanade.domain.category.interactor.UpdateCategory
|
import eu.kanade.domain.category.interactor.UpdateCategory
|
||||||
import eu.kanade.domain.category.model.Category
|
import eu.kanade.domain.category.model.Category
|
||||||
@ -55,7 +55,7 @@ class FavoritesSyncHelper(val context: Context) {
|
|||||||
private val getManga: GetManga by injectLazy()
|
private val getManga: GetManga by injectLazy()
|
||||||
private val updateManga: UpdateManga by injectLazy()
|
private val updateManga: UpdateManga by injectLazy()
|
||||||
private val setMangaCategories: SetMangaCategories by injectLazy()
|
private val setMangaCategories: SetMangaCategories by injectLazy()
|
||||||
private val insertCategory: InsertCategory by injectLazy()
|
private val createCategoryWithName: CreateCategoryWithName by injectLazy()
|
||||||
private val updateCategory: UpdateCategory by injectLazy()
|
private val updateCategory: UpdateCategory by injectLazy()
|
||||||
|
|
||||||
private val prefs: PreferencesHelper by injectLazy()
|
private val prefs: PreferencesHelper by injectLazy()
|
||||||
@ -208,9 +208,10 @@ class FavoritesSyncHelper(val context: Context) {
|
|||||||
|
|
||||||
categories.forEachIndexed { index, remote ->
|
categories.forEachIndexed { index, remote ->
|
||||||
val local = localCategories.getOrElse(index) {
|
val local = localCategories.getOrElse(index) {
|
||||||
when (val insertCategoryResult = insertCategory.await(remote, index.toLong())) {
|
when (val createCategoryWithNameResult = createCategoryWithName.await(remote)) {
|
||||||
is InsertCategory.Result.Error -> throw insertCategoryResult.error
|
is CreateCategoryWithName.Result.InternalError -> throw createCategoryWithNameResult.error
|
||||||
is InsertCategory.Result.Success -> Category(insertCategoryResult.id, remote, index.toLong(), 0L, emptyList())
|
CreateCategoryWithName.Result.NameAlreadyExistsError -> throw IllegalStateException("Category $remote already exists")
|
||||||
|
is CreateCategoryWithName.Result.Success -> createCategoryWithNameResult.category
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -849,4 +849,9 @@
|
|||||||
<string name="pref_navigate_pan">Navigate to pan</string>
|
<string name="pref_navigate_pan">Navigate to pan</string>
|
||||||
<string name="pref_landscape_zoom">Zoom landscape image</string>
|
<string name="pref_landscape_zoom">Zoom landscape image</string>
|
||||||
<string name="cant_open_last_read_chapter">Unable to open last read chapter</string>
|
<string name="cant_open_last_read_chapter">Unable to open last read chapter</string>
|
||||||
|
<string name="delete_category_confirmation">Do you wish to delete the category %s</string>
|
||||||
|
<string name="delete_category">Delete category</string>
|
||||||
|
<string name="yes">Yes</string>
|
||||||
|
<string name="no">No</string>
|
||||||
|
<string name="internal_error">InternalError: Check crash logs for further information</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -381,6 +381,8 @@
|
|||||||
<string name="information_empty_tags">You have no tags. Tap the plus button to create one for sorting your library by tags</string>
|
<string name="information_empty_tags">You have no tags. Tap the plus button to create one for sorting your library by tags</string>
|
||||||
<string name="error_tag_exists">This tag exists!</string>
|
<string name="error_tag_exists">This tag exists!</string>
|
||||||
<string name="snack_tags_deleted">Tags deleted</string>
|
<string name="snack_tags_deleted">Tags deleted</string>
|
||||||
|
<string name="delete_tag">Delete tag</string>
|
||||||
|
<string name="delete_tag_confirmation">Do you wish to delete the tag %s</string>
|
||||||
|
|
||||||
<!-- Extension section -->
|
<!-- Extension section -->
|
||||||
<string name="ext_redundant">Redundant</string>
|
<string name="ext_redundant">Redundant</string>
|
||||||
@ -399,6 +401,8 @@
|
|||||||
<string name="snack_repo_deleted">Repo deleted</string>
|
<string name="snack_repo_deleted">Repo deleted</string>
|
||||||
<string name="invalid_repo_name">Invalid repo name</string>
|
<string name="invalid_repo_name">Invalid repo name</string>
|
||||||
<string name="repo_source">Repo source</string>
|
<string name="repo_source">Repo source</string>
|
||||||
|
<string name="delete_repo">Delete repo</string>
|
||||||
|
<string name="delete_repo_confirmation">Do you wish to delete the repo %s</string>
|
||||||
|
|
||||||
<!-- Migration -->
|
<!-- Migration -->
|
||||||
<string name="select_sources">Select sources</string>
|
<string name="select_sources">Select sources</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user