mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-03 23:58:55 +01:00 
			
		
		
		
	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>
This commit is contained in:
		@@ -4,7 +4,7 @@ import eu.kanade.data.DatabaseHandler
 | 
			
		||||
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(
 | 
			
		||||
@@ -31,31 +31,39 @@ class CategoryRepositoryImpl(
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Throws(DuplicateNameException::class)
 | 
			
		||||
    override suspend fun insert(name: String, order: Long) {
 | 
			
		||||
        if (checkDuplicateName(name)) throw DuplicateNameException(name)
 | 
			
		||||
    override suspend fun insert(category: Category) {
 | 
			
		||||
        handler.await {
 | 
			
		||||
            categoriesQueries.insert(
 | 
			
		||||
                name = name,
 | 
			
		||||
                order = order,
 | 
			
		||||
                flags = 0L,
 | 
			
		||||
                name = category.name,
 | 
			
		||||
                order = category.order,
 | 
			
		||||
                flags = category.flags,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @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,
 | 
			
		||||
            )
 | 
			
		||||
            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,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override suspend fun delete(categoryId: Long) {
 | 
			
		||||
        handler.await {
 | 
			
		||||
            categoriesQueries.delete(
 | 
			
		||||
@@ -63,10 +71,4 @@ class CategoryRepositoryImpl(
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override suspend fun checkDuplicateName(name: String): Boolean {
 | 
			
		||||
        return handler
 | 
			
		||||
            .awaitList { categoriesQueries.getCategories() }
 | 
			
		||||
            .any { it.name == name }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,11 @@ import eu.kanade.data.history.HistoryRepositoryImpl
 | 
			
		||||
import eu.kanade.data.manga.MangaRepositoryImpl
 | 
			
		||||
import eu.kanade.data.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()) }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
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,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            categoryRepository.insert(newCategory)
 | 
			
		||||
            Result.Success
 | 
			
		||||
        } catch (e: Exception) {
 | 
			
		||||
            logcat(LogPriority.ERROR, e)
 | 
			
		||||
            Result.InternalError(e)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sealed class Result {
 | 
			
		||||
        object Success : Result()
 | 
			
		||||
        object NameAlreadyExistsError : Result()
 | 
			
		||||
        data class InternalError(val error: Throwable) : Result()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +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 {
 | 
			
		||||
            categoryRepository.insert(name, order)
 | 
			
		||||
            Result.Success
 | 
			
		||||
        } catch (e: Exception) {
 | 
			
		||||
            Result.Error(e)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sealed class Result {
 | 
			
		||||
        object Success : Result()
 | 
			
		||||
        data class Error(val error: Exception) : Result()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
package eu.kanade.domain.category.interactor
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
import eu.kanade.domain.category.model.CategoryUpdate
 | 
			
		||||
import eu.kanade.domain.category.model.anyWithName
 | 
			
		||||
import eu.kanade.domain.category.repository.CategoryRepository
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.logcat
 | 
			
		||||
import kotlinx.coroutines.NonCancellable
 | 
			
		||||
import kotlinx.coroutines.withContext
 | 
			
		||||
import logcat.LogPriority
 | 
			
		||||
 | 
			
		||||
class RenameCategory(
 | 
			
		||||
    private val categoryRepository: CategoryRepository,
 | 
			
		||||
) {
 | 
			
		||||
 | 
			
		||||
    suspend fun await(categoryId: Long, name: String) = withContext(NonCancellable) await@{
 | 
			
		||||
        val categories = categoryRepository.getAll()
 | 
			
		||||
        if (categories.anyWithName(name)) {
 | 
			
		||||
            return@await Result.NameAlreadyExistsError
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val update = CategoryUpdate(
 | 
			
		||||
            id = categoryId,
 | 
			
		||||
            name = name,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            categoryRepository.updatePartial(update)
 | 
			
		||||
            Result.Success
 | 
			
		||||
        } catch (e: Exception) {
 | 
			
		||||
            logcat(LogPriority.ERROR, e)
 | 
			
		||||
            Result.InternalError(e)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun await(category: Category, name: String) = await(category.id, name)
 | 
			
		||||
 | 
			
		||||
    sealed class Result {
 | 
			
		||||
        object Success : Result()
 | 
			
		||||
        object NameAlreadyExistsError : Result()
 | 
			
		||||
        data class InternalError(val error: Throwable) : Result()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
package eu.kanade.domain.category.interactor
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
import eu.kanade.domain.category.model.CategoryUpdate
 | 
			
		||||
import eu.kanade.domain.category.repository.CategoryRepository
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.logcat
 | 
			
		||||
import kotlinx.coroutines.NonCancellable
 | 
			
		||||
import kotlinx.coroutines.withContext
 | 
			
		||||
import logcat.LogPriority
 | 
			
		||||
 | 
			
		||||
class ReorderCategory(
 | 
			
		||||
    private val categoryRepository: CategoryRepository,
 | 
			
		||||
) {
 | 
			
		||||
 | 
			
		||||
    suspend fun await(categoryId: Long, newPosition: Int) = withContext(NonCancellable) await@{
 | 
			
		||||
        val categories = categoryRepository.getAll()
 | 
			
		||||
 | 
			
		||||
        val currentIndex = categories.indexOfFirst { it.id == categoryId }
 | 
			
		||||
        if (currentIndex == newPosition) {
 | 
			
		||||
            return@await Result.Unchanged
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val reorderedCategories = categories.toMutableList()
 | 
			
		||||
        val reorderedCategory = reorderedCategories.removeAt(currentIndex)
 | 
			
		||||
        reorderedCategories.add(newPosition, reorderedCategory)
 | 
			
		||||
 | 
			
		||||
        val updates = reorderedCategories.mapIndexed { index, category ->
 | 
			
		||||
            CategoryUpdate(
 | 
			
		||||
                id = category.id,
 | 
			
		||||
                order = index.toLong(),
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            categoryRepository.updatePartial(updates)
 | 
			
		||||
            Result.Success
 | 
			
		||||
        } catch (e: Exception) {
 | 
			
		||||
            logcat(LogPriority.ERROR, e)
 | 
			
		||||
            Result.InternalError(e)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun await(category: Category, newPosition: Long): Result =
 | 
			
		||||
        await(category.id, newPosition.toInt())
 | 
			
		||||
 | 
			
		||||
    sealed class Result {
 | 
			
		||||
        object Success : Result()
 | 
			
		||||
        object Unchanged : Result()
 | 
			
		||||
        data class InternalError(val error: Throwable) : Result()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,14 +2,16 @@ package eu.kanade.domain.category.interactor
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.category.model.CategoryUpdate
 | 
			
		||||
import eu.kanade.domain.category.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)
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,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()
 | 
			
		||||
 
 | 
			
		||||
@@ -14,15 +14,11 @@ interface CategoryRepository {
 | 
			
		||||
 | 
			
		||||
    fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
 | 
			
		||||
 | 
			
		||||
    @Throws(DuplicateNameException::class)
 | 
			
		||||
    suspend fun insert(name: String, order: Long)
 | 
			
		||||
    suspend fun insert(category: Category)
 | 
			
		||||
 | 
			
		||||
    @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")
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,109 @@
 | 
			
		||||
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 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,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
        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) },
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            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) },
 | 
			
		||||
                    category = dialog.category,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            else -> {}
 | 
			
		||||
        }
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            presenter.events.collectLatest { event ->
 | 
			
		||||
                when (event) {
 | 
			
		||||
                    is CategoryPresenter.Event.CategoryWithNameAlreadyExists -> {
 | 
			
		||||
                        context.toast(R.string.error_category_exists)
 | 
			
		||||
                    }
 | 
			
		||||
                    is CategoryPresenter.Event.InternalError -> {
 | 
			
		||||
                        context.toast(R.string.internal_error)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,39 @@
 | 
			
		||||
package eu.kanade.presentation.category.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.PaddingValues
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyColumn
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyListState
 | 
			
		||||
import androidx.compose.foundation.lazy.itemsIndexed
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun CategoryContent(
 | 
			
		||||
    categories: List<Category>,
 | 
			
		||||
    lazyListState: LazyListState,
 | 
			
		||||
    paddingValues: PaddingValues,
 | 
			
		||||
    onMoveUp: (Category) -> Unit,
 | 
			
		||||
    onMoveDown: (Category) -> Unit,
 | 
			
		||||
    onRename: (Category) -> Unit,
 | 
			
		||||
    onDelete: (Category) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    LazyColumn(
 | 
			
		||||
        state = lazyListState,
 | 
			
		||||
        contentPadding = paddingValues,
 | 
			
		||||
        verticalArrangement = Arrangement.spacedBy(8.dp),
 | 
			
		||||
    ) {
 | 
			
		||||
        itemsIndexed(categories) { index, category ->
 | 
			
		||||
            CategoryListItem(
 | 
			
		||||
                category = category,
 | 
			
		||||
                canMoveUp = index != 0,
 | 
			
		||||
                canMoveDown = index != categories.lastIndex,
 | 
			
		||||
                onMoveUp = onMoveUp,
 | 
			
		||||
                onMoveDown = onMoveDown,
 | 
			
		||||
                onRename = onRename,
 | 
			
		||||
                onDelete = onDelete,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,115 @@
 | 
			
		||||
package eu.kanade.presentation.category.components
 | 
			
		||||
 | 
			
		||||
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.domain.category.model.Category
 | 
			
		||||
import eu.kanade.presentation.components.TextButton
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun CategoryCreateDialog(
 | 
			
		||||
    onDismissRequest: () -> Unit,
 | 
			
		||||
    onCreate: (String) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    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 = stringResource(id = R.string.action_add_category))
 | 
			
		||||
        },
 | 
			
		||||
        text = {
 | 
			
		||||
            OutlinedTextField(
 | 
			
		||||
                value = name,
 | 
			
		||||
                onValueChange = onNameChange,
 | 
			
		||||
                label = {
 | 
			
		||||
                    Text(text = stringResource(id = R.string.name))
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun CategoryRenameDialog(
 | 
			
		||||
    onDismissRequest: () -> Unit,
 | 
			
		||||
    onRename: (String) -> Unit,
 | 
			
		||||
    category: Category,
 | 
			
		||||
) {
 | 
			
		||||
    val (name, onNameChange) = remember { mutableStateOf(category.name) }
 | 
			
		||||
    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,
 | 
			
		||||
    category: Category,
 | 
			
		||||
) {
 | 
			
		||||
    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 = stringResource(R.string.delete_category))
 | 
			
		||||
        },
 | 
			
		||||
        text = {
 | 
			
		||||
            Text(text = stringResource(R.string.delete_category_confirmation, category.name))
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,30 @@
 | 
			
		||||
package eu.kanade.presentation.category.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.navigationBarsPadding
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyListState
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.outlined.Add
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import eu.kanade.presentation.components.ExtendedFloatingActionButton
 | 
			
		||||
import eu.kanade.presentation.util.isScrolledToEnd
 | 
			
		||||
import eu.kanade.presentation.util.isScrollingUp
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun CategoryFloatingActionButton(
 | 
			
		||||
    lazyListState: LazyListState,
 | 
			
		||||
    onCreate: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    ExtendedFloatingActionButton(
 | 
			
		||||
        text = { Text(text = stringResource(id = R.string.action_add)) },
 | 
			
		||||
        icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = "") },
 | 
			
		||||
        onClick = onCreate,
 | 
			
		||||
        modifier = Modifier
 | 
			
		||||
            .navigationBarsPadding(),
 | 
			
		||||
        expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(),
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,63 @@
 | 
			
		||||
package eu.kanade.presentation.category.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.Spacer
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.outlined.ArrowDropDown
 | 
			
		||||
import androidx.compose.material.icons.outlined.ArrowDropUp
 | 
			
		||||
import androidx.compose.material.icons.outlined.Delete
 | 
			
		||||
import androidx.compose.material.icons.outlined.Edit
 | 
			
		||||
import androidx.compose.material.icons.outlined.Label
 | 
			
		||||
import androidx.compose.material3.ElevatedCard
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.IconButton
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
import eu.kanade.presentation.util.horizontalPadding
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun CategoryListItem(
 | 
			
		||||
    category: Category,
 | 
			
		||||
    canMoveUp: Boolean,
 | 
			
		||||
    canMoveDown: Boolean,
 | 
			
		||||
    onMoveUp: (Category) -> Unit,
 | 
			
		||||
    onMoveDown: (Category) -> Unit,
 | 
			
		||||
    onRename: (Category) -> Unit,
 | 
			
		||||
    onDelete: (Category) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    ElevatedCard {
 | 
			
		||||
        Row(
 | 
			
		||||
            modifier = Modifier
 | 
			
		||||
                .padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
 | 
			
		||||
            verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
        ) {
 | 
			
		||||
            Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
 | 
			
		||||
            Text(text = category.name, modifier = Modifier.padding(start = horizontalPadding))
 | 
			
		||||
        }
 | 
			
		||||
        Row {
 | 
			
		||||
            IconButton(
 | 
			
		||||
                onClick = { onMoveUp(category) },
 | 
			
		||||
                enabled = canMoveUp,
 | 
			
		||||
            ) {
 | 
			
		||||
                Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
 | 
			
		||||
            }
 | 
			
		||||
            IconButton(
 | 
			
		||||
                onClick = { onMoveDown(category) },
 | 
			
		||||
                enabled = canMoveDown,
 | 
			
		||||
            ) {
 | 
			
		||||
                Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
 | 
			
		||||
            }
 | 
			
		||||
            Spacer(modifier = Modifier.weight(1f))
 | 
			
		||||
            IconButton(onClick = { onRename(category) }) {
 | 
			
		||||
                Icon(imageVector = Icons.Outlined.Edit, contentDescription = "")
 | 
			
		||||
            }
 | 
			
		||||
            IconButton(onClick = { onDelete(category) }) {
 | 
			
		||||
                Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,33 @@
 | 
			
		||||
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,
 | 
			
		||||
) {
 | 
			
		||||
    SmallTopAppBar(
 | 
			
		||||
        navigationIcon = {
 | 
			
		||||
            IconButton(onClick = navigateUp) {
 | 
			
		||||
                Icon(
 | 
			
		||||
                    imageVector = Icons.Default.ArrowBack,
 | 
			
		||||
                    contentDescription = stringResource(R.string.abc_action_bar_up_description),
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        title = {
 | 
			
		||||
            Text(text = stringResource(id = R.string.action_edit_categories))
 | 
			
		||||
        },
 | 
			
		||||
        scrollBehavior = topAppBarScrollBehavior,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
import eu.davidea.flexibleadapter.FlexibleAdapter
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Custom adapter for categories.
 | 
			
		||||
 *
 | 
			
		||||
 * @param controller The containing controller.
 | 
			
		||||
 */
 | 
			
		||||
class CategoryAdapter(controller: CategoryController) :
 | 
			
		||||
    FlexibleAdapter<CategoryItem>(null, controller, true) {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Listener called when an item of the list is released.
 | 
			
		||||
     */
 | 
			
		||||
    val onItemReleaseListener: OnItemReleaseListener = controller
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clears the active selections from the list and the model.
 | 
			
		||||
     */
 | 
			
		||||
    override fun clearSelection() {
 | 
			
		||||
        super.clearSelection()
 | 
			
		||||
        (0 until itemCount).forEach { getItem(it)?.isSelected = false }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Toggles the selection of the given position.
 | 
			
		||||
     *
 | 
			
		||||
     * @param position The position to toggle.
 | 
			
		||||
     */
 | 
			
		||||
    override fun toggleSelection(position: Int) {
 | 
			
		||||
        super.toggleSelection(position)
 | 
			
		||||
        getItem(position)?.isSelected = isSelected(position)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    interface OnItemReleaseListener {
 | 
			
		||||
        /**
 | 
			
		||||
         * Called when an item of the list is released.
 | 
			
		||||
         */
 | 
			
		||||
        fun onItemReleased(position: Int)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,357 +1,18 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,48 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
import android.app.Dialog
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import com.bluelinelabs.conductor.Controller
 | 
			
		||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
 | 
			
		||||
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Dialog to create a new category for the library.
 | 
			
		||||
 */
 | 
			
		||||
class CategoryCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
 | 
			
		||||
        where T : Controller, T : CategoryCreateDialog.Listener {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Name of the new category. Value updated with each input from the user.
 | 
			
		||||
     */
 | 
			
		||||
    private var currentName = ""
 | 
			
		||||
 | 
			
		||||
    constructor(target: T) : this() {
 | 
			
		||||
        targetController = target
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Called when creating the dialog for this controller.
 | 
			
		||||
     *
 | 
			
		||||
     * @param savedViewState The saved state of this dialog.
 | 
			
		||||
     * @return a new dialog instance.
 | 
			
		||||
     */
 | 
			
		||||
    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
 | 
			
		||||
        return MaterialAlertDialogBuilder(activity!!)
 | 
			
		||||
            .setTitle(R.string.action_add_category)
 | 
			
		||||
            .setTextInput(prefill = currentName) {
 | 
			
		||||
                currentName = it
 | 
			
		||||
            }
 | 
			
		||||
            .setPositiveButton(android.R.string.ok) { _, _ ->
 | 
			
		||||
                (targetController as? Listener)?.createCategory(currentName)
 | 
			
		||||
            }
 | 
			
		||||
            .setNegativeButton(android.R.string.cancel, null)
 | 
			
		||||
            .create()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    interface Listener {
 | 
			
		||||
        fun createCategory(name: String)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,49 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
import android.view.View
 | 
			
		||||
import androidx.recyclerview.widget.ItemTouchHelper
 | 
			
		||||
import eu.davidea.viewholders.FlexibleViewHolder
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Holder used to display category items.
 | 
			
		||||
 *
 | 
			
		||||
 * @param view The view used by category items.
 | 
			
		||||
 * @param adapter The adapter containing this holder.
 | 
			
		||||
 */
 | 
			
		||||
class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHolder(view, adapter) {
 | 
			
		||||
 | 
			
		||||
    private val binding = CategoriesItemBinding.bind(view)
 | 
			
		||||
 | 
			
		||||
    init {
 | 
			
		||||
        setDragHandleView(binding.reorder)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Binds this holder with the given category.
 | 
			
		||||
     *
 | 
			
		||||
     * @param category The category to bind.
 | 
			
		||||
     */
 | 
			
		||||
    fun bind(category: Category) {
 | 
			
		||||
        binding.title.text = category.name
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Called when an item is released.
 | 
			
		||||
     *
 | 
			
		||||
     * @param position The position of the released item.
 | 
			
		||||
     */
 | 
			
		||||
    override fun onItemReleased(position: Int) {
 | 
			
		||||
        super.onItemReleased(position)
 | 
			
		||||
        adapter.onItemReleaseListener.onItemReleased(position)
 | 
			
		||||
        binding.container.isDragged = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun onActionStateChanged(position: Int, actionState: Int) {
 | 
			
		||||
        super.onActionStateChanged(position, actionState)
 | 
			
		||||
        if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
 | 
			
		||||
            binding.container.isDragged = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,73 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
import android.view.View
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import eu.davidea.flexibleadapter.FlexibleAdapter
 | 
			
		||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
 | 
			
		||||
import eu.davidea.flexibleadapter.items.IFlexible
 | 
			
		||||
import eu.kanade.domain.category.model.Category
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Category item for a recycler view.
 | 
			
		||||
 */
 | 
			
		||||
class CategoryItem(val category: Category) : AbstractFlexibleItem<CategoryHolder>() {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether this item is currently selected.
 | 
			
		||||
     */
 | 
			
		||||
    var isSelected = false
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the layout resource for this item.
 | 
			
		||||
     */
 | 
			
		||||
    override fun getLayoutRes(): Int {
 | 
			
		||||
        return R.layout.categories_item
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns a new view holder for this item.
 | 
			
		||||
     *
 | 
			
		||||
     * @param view The view of this item.
 | 
			
		||||
     * @param adapter The adapter of this item.
 | 
			
		||||
     */
 | 
			
		||||
    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): CategoryHolder {
 | 
			
		||||
        return CategoryHolder(view, adapter as CategoryAdapter)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Binds the given view holder with this item.
 | 
			
		||||
     *
 | 
			
		||||
     * @param adapter The adapter of this item.
 | 
			
		||||
     * @param holder The holder to bind.
 | 
			
		||||
     * @param position The position of this item in the adapter.
 | 
			
		||||
     * @param payloads List of partial changes.
 | 
			
		||||
     */
 | 
			
		||||
    override fun bindViewHolder(
 | 
			
		||||
        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
 | 
			
		||||
        holder: CategoryHolder,
 | 
			
		||||
        position: Int,
 | 
			
		||||
        payloads: List<Any?>?,
 | 
			
		||||
    ) {
 | 
			
		||||
        holder.bind(category)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns true if this item is draggable.
 | 
			
		||||
     */
 | 
			
		||||
    override fun isDraggable(): Boolean {
 | 
			
		||||
        return true
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun equals(other: Any?): Boolean {
 | 
			
		||||
        if (this === other) return true
 | 
			
		||||
        if (other is CategoryItem) {
 | 
			
		||||
            return category.id == other.category.id
 | 
			
		||||
        }
 | 
			
		||||
        return false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun hashCode(): Int {
 | 
			
		||||
        return category.id.hashCode()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,130 +1,91 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.category
 | 
			
		||||
 | 
			
		||||
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()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
		Reference in New Issue
	
	Block a user