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:
Andreas 2022-07-09 18:31:14 +02:00 committed by Jobobby04
parent 6651ba7211
commit 4e29fd5b2a
79 changed files with 1767 additions and 2739 deletions

View File

@ -5,7 +5,7 @@ import eu.kanade.data.listOfLongsAdapter
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepository
import eu.kanade.domain.category.repository.DuplicateNameException
import eu.kanade.tachiyomi.Database
import kotlinx.coroutines.flow.Flow
class CategoryRepositoryImpl(
@ -33,16 +33,14 @@ class CategoryRepositoryImpl(
}
// SY -->
@Throws(DuplicateNameException::class)
override suspend fun insert(name: String, order: Long): Long {
if (checkDuplicateName(name)) throw DuplicateNameException(name)
override suspend fun insert(category: Category): Long {
return handler.awaitOne(true) {
categoriesQueries.insert(
name = name,
order = order,
flags = 0L,
name = category.name,
order = category.order,
flags = category.flags,
// SY -->
mangaOrder = emptyList(),
mangaOrder = category.mangaOrder,
// SY <--
)
categoriesQueries.selectLastInsertedRowId()
@ -50,22 +48,32 @@ class CategoryRepositoryImpl(
}
// SY <--
@Throws(DuplicateNameException::class)
override suspend fun update(payload: CategoryUpdate) {
if (payload.name != null && checkDuplicateName(payload.name)) throw DuplicateNameException(payload.name)
override suspend fun updatePartial(update: CategoryUpdate) {
handler.await {
categoriesQueries.update(
name = payload.name,
order = payload.order,
flags = payload.flags,
categoryId = payload.id,
// SY -->
mangaOrder = payload.mangaOrder?.let(listOfLongsAdapter::encode),
// SY <--
)
updatePartialBlocking(update)
}
}
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) {
handler.await {
categoriesQueries.delete(
@ -73,10 +81,4 @@ class CategoryRepositoryImpl(
)
}
}
override suspend fun checkDuplicateName(name: String): Boolean {
return handler
.awaitList { categoriesQueries.getCategories() }
.any { it.name == name }
}
}

View File

@ -6,9 +6,11 @@ import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
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.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.UpdateCategory
import eu.kanade.domain.category.repository.CategoryRepository
@ -69,7 +71,9 @@ class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<CategoryRepository> { CategoryRepositoryImpl(get()) }
addFactory { GetCategories(get()) }
addFactory { InsertCategory(get()) }
addFactory { CreateCategoryWithName(get()) }
addFactory { RenameCategory(get()) }
addFactory { ReorderCategory(get()) }
addFactory { UpdateCategory(get()) }
addFactory { DeleteCategory(get()) }

View File

@ -9,10 +9,12 @@ import eu.kanade.domain.chapter.interactor.DeleteChapters
import eu.kanade.domain.chapter.interactor.GetChapterByUrl
import eu.kanade.domain.chapter.interactor.GetMergedChapterByMangaId
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.DeleteFavoriteEntries
import eu.kanade.domain.manga.interactor.DeleteMangaById
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.GetExhFavoriteMangaWithMetadata
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.GetSearchTags
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.InsertFlatMetadata
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.UpdateMergedSettings
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.source.interactor.CountFeedSavedSearchBySourceId
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.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.GetFeedSavedSearchBySourceId
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.GetShowLatest
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.InsertSavedSearch
import eu.kanade.domain.source.interactor.RenameSourceCategory
import eu.kanade.domain.source.interactor.SetSourceCategories
import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
import eu.kanade.domain.source.interactor.ToggleSources
@ -64,7 +74,6 @@ import xyz.nulldev.ts.api.http.serializer.FilterSerializer
class SYDomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addFactory { GetSourceCategories(get()) }
addFactory { GetShowLatest(get()) }
addFactory { ToggleExcludeFromDataSaver(get()) }
addFactory { SetSourceCategories(get()) }
@ -77,6 +86,17 @@ class SYDomainModule : InjektModule {
addFactory { FilterSerializer() }
addFactory { GetHistoryByMangaId(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()) }
addFactory { GetFlatMetadataById(get()) }

View File

@ -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()
}
}

View File

@ -1,12 +1,43 @@
package eu.kanade.domain.category.interactor
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 DeleteCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(categoryId: Long) {
categoryRepository.delete(categoryId)
suspend fun await(categoryId: Long) = withContext(NonCancellable) await@{
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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -2,14 +2,16 @@ package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepository
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class UpdateCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(payload: CategoryUpdate): Result {
return try {
categoryRepository.update(payload)
suspend fun await(payload: CategoryUpdate): Result = withContext(NonCancellable) {
try {
categoryRepository.updatePartial(payload)
Result.Success
} catch (e: Exception) {
Result.Error(e)

View File

@ -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 {
it.name = name
it.id = id.toInt()

View File

@ -15,16 +15,12 @@ interface CategoryRepository {
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
// SY -->
@Throws(DuplicateNameException::class)
suspend fun insert(name: String, order: Long): Long
suspend fun insert(category: Category): Long
// SY <--
@Throws(DuplicateNameException::class)
suspend fun update(payload: CategoryUpdate)
suspend fun updatePartial(update: CategoryUpdate)
suspend fun updatePartial(updates: List<CategoryUpdate>)
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")

View File

@ -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()}"
}
}

View File

@ -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)
}
}
}

View File

@ -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 }
}
}

View File

@ -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()
}
}

View File

@ -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) }
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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(),
)
}
}

View File

@ -2,12 +2,13 @@ package eu.kanade.domain.source.interactor
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetSourceCategories(
private val preferences: PreferencesHelper,
) {
fun subscribe(): Flow<Set<String>> {
return preferences.sourcesTabCategories().asFlow()
fun subscribe(): Flow<List<String>> {
return preferences.sourcesTabCategories().asFlow().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
}
}

View File

@ -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) }
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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)
},
)
}

View File

@ -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(),
)
}

View File

@ -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 = "")
}
}
}
}

View File

@ -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,
)
}

View File

@ -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,
)
}
}
}

View File

@ -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 = "")
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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 = "")
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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 = "")
}
}
}
}

View File

@ -60,8 +60,8 @@ fun ChapterDownloadIndicator(
},
) {
val indicatorModifier = Modifier
.size(IndicatorSize)
.padding(IndicatorPadding)
.size(IndicatorSize)
.padding(IndicatorPadding)
if (isDownloaded) {
Icon(
imageVector = Icons.Default.CheckCircle,
@ -151,5 +151,6 @@ fun ChapterDownloadIndicator(
private val IndicatorSize = 26.dp
private val IndicatorPadding = 2.dp
// To match composable parameter name when used later
private val IndicatorStrokeWidth = IndicatorPadding

View File

@ -3,6 +3,12 @@ package eu.kanade.presentation.util
import androidx.compose.foundation.layout.PaddingValues
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)

View File

@ -230,8 +230,8 @@ class MergedSource : HttpSource() {
val id = insertManga.await(
Manga.create().copy(
source = mangaSourceId,
url = mangaUrl
)
url = mangaUrl,
),
)!!
val newManga = getManga.await(id)!!
updateManga.awaitUpdateFromSource(newManga, source.getMangaDetails(newManga.toMangaInfo()), false)

View File

@ -59,7 +59,7 @@ class SourcesPresenter(
// 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 ->
// Sources without a lang defined will be placed at the end
when {

View File

@ -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)
}
}

View File

@ -1,357 +1,18 @@
package eu.kanade.tachiyomi.ui.category
import android.view.LayoutInflater
import android.view.Menu
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.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
import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.CategoryScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
/**
* 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 {
class CategoryController : FullComposeController<CategoryPresenter>() {
/**
* 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()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_categories)
}
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)
@Composable
override fun ComposeContent() {
CategoryScreen(
presenter = presenter,
navigateUp = router::popCurrentController,
)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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()
}
}

View File

@ -1,130 +1,91 @@
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.GetCategories
import eu.kanade.domain.category.interactor.InsertCategory
import eu.kanade.domain.category.interactor.UpdateCategory
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.ReorderCategory
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.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import logcat.LogPriority
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [CategoryController]. Used to manage the categories of the library.
*/
class CategoryPresenter(
private val getCategories: GetCategories = Injekt.get(),
private val insertCategory: InsertCategory = Injekt.get(),
private val updateCategory: UpdateCategory = Injekt.get(),
private val createCategoryWithName: CreateCategoryWithName = Injekt.get(),
private val renameCategory: RenameCategory = Injekt.get(),
private val reorderCategory: ReorderCategory = Injekt.get(),
private val deleteCategory: DeleteCategory = Injekt.get(),
) : BasePresenter<CategoryController>() {
private val _categories: MutableStateFlow<List<Category>> = MutableStateFlow(listOf())
val categories = _categories.asStateFlow()
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)
val categories = getCategories.subscribe()
presenterScope.launchIO {
getCategories.subscribe()
.collectLatest { list ->
_categories.value = list
}
}
}
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
/**
* Creates and adds a new category to the database.
*
* @param name The name of the category to create.
*/
fun createCategory(name: String) {
presenterScope.launchIO {
val result = insertCategory.await(
name = name,
order = categories.value.map { it.order + 1L }.maxOrNull() ?: 0L,
)
when (result) {
is InsertCategory.Result.Success -> {}
is InsertCategory.Result.Error -> {
logcat(LogPriority.ERROR, result.error)
if (result.error is DuplicateNameException) {
launchUI { view?.onCategoryExistsError() }
}
}
when (createCategoryWithName.await(name)) {
is CreateCategoryWithName.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is CreateCategoryWithName.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
/**
* Deletes the given categories from the database.
*
* @param categories The list of categories to delete.
*/
fun deleteCategories(categories: List<Category>) {
fun deleteCategory(category: Category) {
presenterScope.launchIO {
categories.forEach { category ->
deleteCategory.await(category.id)
when (deleteCategory.await(category.id)) {
is DeleteCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
/**
* Reorders the given categories in the database.
*
* @param categories The list of categories to reorder.
*/
fun reorderCategories(categories: List<Category>) {
fun moveUp(category: Category) {
presenterScope.launchIO {
categories.forEachIndexed { order, category ->
updateCategory.await(
payload = CategoryUpdate(
id = category.id,
order = order.toLong(),
),
)
when (reorderCategory.await(category, category.order - 1)) {
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
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) {
presenterScope.launchIO {
val result = updateCategory.await(
payload = CategoryUpdate(
id = category.id,
name = name,
),
)
when (result) {
is UpdateCategory.Result.Success -> {}
is UpdateCategory.Result.Error -> {
logcat(LogPriority.ERROR, result.error)
if (result.error is DuplicateNameException) {
launchUI { view?.onCategoryExistsError() }
}
}
when (renameCategory.await(category, name)) {
RenameCategory.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is RenameCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
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()
}
}

View File

@ -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"

View File

@ -25,12 +25,10 @@ import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.ExperimentalTime
/**
* Controller to manage the lock times for the biometric lock.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesController :
NucleusController<CategoriesControllerBinding, BiometricTimesPresenter>(),
FabController,

View File

@ -6,12 +6,10 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import kotlin.time.ExperimentalTime
/**
* Category item for a recycler view.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesItem(val timeRange: TimeRange) : AbstractFlexibleItem<BiometricTimesHolder>() {
/**

View File

@ -3,20 +3,17 @@ package eu.kanade.tachiyomi.ui.category.biometric
import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
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 exh.log.xLogD
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.time.ExperimentalTime
/**
* Presenter of [BiometricTimesController]. Used to manage the categories of the library.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
/**
@ -36,12 +33,11 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
preferences.authenticatorTimeRanges().asFlow().onEach { prefTimeRanges ->
timeRanges = prefTimeRanges.toList()
.mapNotNull { TimeRange.fromPreferenceString(it) }.onEach { xLogD(it) }
.mapNotNull(TimeRange::fromPreferenceString)
Observable.just(timeRanges)
.map { it.map(::BiometricTimesItem) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(BiometricTimesController::setBiometricTimeItems)
withUIContext {
view?.setBiometricTimeItems(timeRanges.map(::BiometricTimesItem))
}
}.launchIn(presenterScope)
}
@ -53,12 +49,12 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
fun createTimeRange(timeRange: TimeRange) {
// Do not allow duplicate categories.
if (timeRangeConflicts(timeRange)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onTimeRangeConflictsError() })
launchUI {
view?.onTimeRangeConflictsError()
}
return
}
xLogD(timeRange)
preferences.authenticatorTimeRanges() += timeRange.toPreferenceString()
}
@ -69,7 +65,7 @@ class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
*/
fun deleteTimeRanges(timeRanges: List<TimeRange>) {
preferences.authenticatorTimeRanges().set(
this.timeRanges.filterNot { it in timeRanges }.map { it.toPreferenceString() }.toSet(),
this.timeRanges.filterNot { it in timeRanges }.map(TimeRange::toPreferenceString).toSet(),
)
}

View File

@ -40,11 +40,13 @@ data class TimeRange(private val startTime: Duration, private val endTime: Durat
companion object {
fun fromPreferenceString(timeRange: String): TimeRange? {
return timeRange.split(",").mapNotNull { it.toDoubleOrNull() }.let {
if (it.size != 2) null else {
TimeRange(it[0].minutes, it[1].minutes)
}
}
val index = timeRange.indexOf(',')
return if (index != -1) {
TimeRange(
timeRange.substring(0, index).toDoubleOrNull()?.minutes ?: return null,
timeRange.substring(index + 1).toDoubleOrNull()?.minutes ?: return null,
)
} else return null
}
}
}

View File

@ -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)
}
}

View File

@ -1,357 +1,21 @@
package eu.kanade.tachiyomi.ui.category.genre
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
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
import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.SortTagScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
/**
* Controller to manage the categories for the users' library.
*/
class SortTagController :
NucleusController<CategoriesControllerBinding, SortTagPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SortTagAdapter.OnItemReleaseListener,
SortTagCreateDialog.Listener,
UndoHelper.OnActionListener {
class SortTagController : FullComposeController<SortTagPresenter>() {
/**
* 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()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_tags)
}
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)
@Composable
override fun ComposeContent() {
SortTagScreen(
presenter = presenter,
navigateUp = router::popCurrentController,
)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -1,93 +1,79 @@
package eu.kanade.tachiyomi.ui.category.genre
import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import androidx.compose.runtime.getValue
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.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* 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.
*/
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) {
// Do not allow duplicate categories.
if (tagExists(name.trim())) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onTagExistsError() })
return
}
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
presenterScope.launchIO {
when (createSortTag.await(name)) {
is CreateSortTag.Result.TagExists -> _events.send(Event.TagExists)
else -> {}
}
}
}
/**
* Reorders the given categories in the database.
*
* @param tags The list of categories to reorder.
*/
fun reorderTags(tags: List<String>) {
preferences.sortTagsForLibrary().set(tags.mapIndexed { index, tag -> "$index|$tag" }.toSet())
fun delete(tag: String) {
presenterScope.launchIO {
deleteSortTag.await(tag)
}
}
/**
* Returns true if a category with the given name already exists.
*/
private fun tagExists(name: String): Boolean {
return tags.any { it.equals(name) }
fun moveUp(tag: String, index: Int) {
presenterScope.launchIO {
when (reorderSortTag.await(tag, index - 1)) {
is ReorderSortTag.Result.InternalError -> _events.send(Event.InternalError)
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()
}
}

View File

@ -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)
}
}

View File

@ -1,313 +1,21 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.view.LayoutInflater
import android.view.Menu
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.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
import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.SourceRepoScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
/**
* Controller to manage the categories for the users' library.
*/
class RepoController :
NucleusController<CategoriesControllerBinding, RepoPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
RepoCreateDialog.Listener,
UndoHelper.OnActionListener {
class RepoController : FullComposeController<RepoPresenter>() {
/**
* 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()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_repos)
}
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)
@Composable
override fun ComposeContent() {
SourceRepoScreen(
presenter = presenter,
navigateUp = router::popCurrentController,
)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -1,12 +1,15 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import androidx.compose.runtime.getValue
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 kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
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.
*/
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>() {
/**
* List containing repos.
*/
private var repos: List<String> = emptyList()
/**
* Called when the presenter is created.
*
* @param savedState The saved state of this presenter.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
var dialog: Dialog? by mutableStateOf(null)
preferences.extensionRepos().asFlow().onEach { repos ->
this.repos = repos.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it }))
val repos = getSourceRepos.subscribe()
Observable.just(this.repos)
.map { it.map(::RepoItem) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(RepoController::setRepos)
}.launchIn(presenterScope)
}
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
/**
* Creates and adds a new repo to the database.
@ -45,19 +35,13 @@ class RepoPresenter(
* @param name The name of the repo to create.
*/
fun createRepo(name: String) {
// Do not allow duplicate repos.
if (repoExists(name)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onRepoExistsError() })
return
presenterScope.launchIO {
when (createSourceRepo.await(name)) {
is CreateSourceRepo.Result.RepoExists -> _events.send(Event.RepoExists)
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.
*/
fun deleteRepos(repos: List<String>) {
preferences.extensionRepos().set(
this.repos.filterNot { it in repos }.toSet(),
)
presenterScope.launchIO {
deleteSourceRepos.await(repos)
}
}
/**
* Returns true if a repo with the given name already exists.
*/
private fun repoExists(name: String): Boolean {
return repos.any { it.equals(name, true) }
sealed class Event {
object RepoExists : Event()
object InvalidName : Event()
object InternalError : Event()
}
companion object {
val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex()
sealed class Dialog {
object Create : Dialog()
data class Delete(val repo: String) : Dialog()
}
}

View File

@ -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>)
}
}

View File

@ -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)
}
}

View File

@ -1,343 +1,21 @@
package eu.kanade.tachiyomi.ui.category.sources
import android.view.LayoutInflater
import android.view.Menu
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.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
import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.SourceCategoryScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
/**
* Controller to manage the categories for the users' library.
*/
class SourceCategoryController :
NucleusController<CategoriesControllerBinding, SourceCategoryPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SourceCategoryCreateDialog.Listener,
SourceCategoryRenameDialog.Listener,
UndoHelper.OnActionListener {
class SourceCategoryController : FullComposeController<SourceCategoryPresenter>() {
/**
* 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()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_categories)
}
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)
@Composable
override fun ComposeContent() {
SourceCategoryScreen(
presenter = presenter,
navigateUp = router::popCurrentController,
)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -1,44 +1,35 @@
package eu.kanade.tachiyomi.ui.category.sources
import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import androidx.compose.runtime.getValue
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 kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* 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>() {
/**
* List containing categories.
*/
private var categories: List<String> = emptyList()
var dialog: Dialog? by mutableStateOf(null)
val preferences: PreferencesHelper = Injekt.get()
val categories = getSourceCategories.subscribe()
/**
* Called when the presenter is created.
*
* @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)
}
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
/**
* 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.
*/
fun createCategory(name: String) {
// Do not allow duplicate categories.
if (categoryExists(name)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
return
presenterScope.launchIO {
when (createSourceCategory.await(name)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
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.
*/
fun deleteCategories(categories: List<String>) {
var sources = preferences.sourcesTabSourcesInCategories().get().toList()
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(),
)
fun deleteCategory(categories: String) {
presenterScope.launchIO {
deleteSourceCategory.await(categories)
}
}
/**
* Renames a category.
*
* @param category The category to rename.
* @param name The new name of the category.
* @param categoryOld The category to rename.
* @param categoryNew The new name of the category.
*/
fun renameCategory(categoryOld: String, categoryNew: String) {
// Do not allow duplicate categories.
if (categoryExists(categoryNew)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
return
}
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]
presenterScope.launchIO {
when (renameSourceCategory.await(categoryOld, categoryNew)) {
is CreateSourceCategory.Result.CategoryExists -> _events.send(Event.CategoryExists)
is CreateSourceCategory.Result.InvalidName -> _events.send(Event.InvalidName)
else -> {}
}
preferences.sourcesTabSourcesInCategories().set(sources.toSet())
preferences.sourcesTabCategories().set(newCategories.sorted().toSet())
}
}
/**
* Returns true if a category with the given name already exists.
*/
private fun categoryExists(name: String): Boolean {
return categories.any { it.equals(name, true) }
sealed class Event {
object CategoryExists : Event()
object InvalidName : Event()
object InternalError : Event()
}
sealed class Dialog {
object Create : Dialog()
data class Rename(val category: String) : Dialog()
data class Delete(val category: String) : Dialog()
}
}

View File

@ -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"
}
}

View File

@ -11,7 +11,6 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.ui.category.CategoryAdapter
import eu.kanade.tachiyomi.util.lang.withUIContext
import exh.log.xLogW
import exh.metadata.sql.models.SearchTag
@ -70,10 +69,6 @@ class LibraryCategoryAdapter(view: LibraryCategoryView, val controller: LibraryC
*/
private var mangas: List<LibraryItem> = emptyList()
// SY -->
val onItemReleaseListener: CategoryAdapter.OnItemReleaseListener = view
// SY <--
/**
* Sets a list of manga in the adapter.
*

View File

@ -20,7 +20,6 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
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.SortModeSetting
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -53,8 +52,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
// SY -->
FlexibleAdapter.OnItemMoveListener,
CategoryAdapter.OnItemReleaseListener {
FlexibleAdapter.OnItemMoveListener {
// SY <--
private val scope = MainScope()
@ -411,10 +409,6 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
else -> null
}
override fun onItemReleased(position: Int) {
return
}
override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean {
if (adapter.isSelected(fromPosition)) toggleSelection(fromPosition)
return true

View File

@ -28,16 +28,6 @@ abstract class LibraryHolder<VB : ViewBinding>(
abstract fun onSetValues(item: LibraryItem)
// 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 {
return if (adapter.isLongPressDragEnabled) {
super.onLongClick(view)

View File

@ -123,8 +123,8 @@ class GalleryAdder(
insertManga.await(
Manga.create().copy(
source = source.id,
url = cleanedMangaUrl
)
url = cleanedMangaUrl,
),
)
getManga.await(cleanedMangaUrl, source.id)!!
}

View File

@ -3,8 +3,8 @@ package exh.favorites
import android.content.Context
import android.net.wifi.WifiManager
import android.os.PowerManager
import eu.kanade.domain.category.interactor.CreateCategoryWithName
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.UpdateCategory
import eu.kanade.domain.category.model.Category
@ -55,7 +55,7 @@ class FavoritesSyncHelper(val context: Context) {
private val getManga: GetManga by injectLazy()
private val updateManga: UpdateManga 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 prefs: PreferencesHelper by injectLazy()
@ -208,9 +208,10 @@ class FavoritesSyncHelper(val context: Context) {
categories.forEachIndexed { index, remote ->
val local = localCategories.getOrElse(index) {
when (val insertCategoryResult = insertCategory.await(remote, index.toLong())) {
is InsertCategory.Result.Error -> throw insertCategoryResult.error
is InsertCategory.Result.Success -> Category(insertCategoryResult.id, remote, index.toLong(), 0L, emptyList())
when (val createCategoryWithNameResult = createCategoryWithName.await(remote)) {
is CreateCategoryWithName.Result.InternalError -> throw createCategoryWithNameResult.error
CreateCategoryWithName.Result.NameAlreadyExistsError -> throw IllegalStateException("Category $remote already exists")
is CreateCategoryWithName.Result.Success -> createCategoryWithNameResult.category
}
}

View File

@ -849,4 +849,9 @@
<string name="pref_navigate_pan">Navigate to pan</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="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>

View File

@ -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="error_tag_exists">This tag exists!</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 -->
<string name="ext_redundant">Redundant</string>
@ -399,6 +401,8 @@
<string name="snack_repo_deleted">Repo deleted</string>
<string name="invalid_repo_name">Invalid repo name</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 -->
<string name="select_sources">Select sources</string>