From 86bacbe586bfe5567b1d52eb8d7b7f23724a17d5 Mon Sep 17 00:00:00 2001 From: Andreas Date: Sat, 9 Jul 2022 18:31:14 +0200 Subject: [PATCH] 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> --- .../data/category/CategoryRepositoryImpl.kt | 46 +-- .../java/eu/kanade/domain/DomainModule.kt | 8 +- .../interactor/CreateCategoryWithName.kt | 43 +++ .../category/interactor/DeleteCategory.kt | 35 +- .../category/interactor/InsertCategory.kt | 22 -- .../category/interactor/RenameCategory.kt | 43 +++ .../category/interactor/ReorderCategory.kt | 51 +++ .../category/interactor/UpdateCategory.kt | 8 +- .../kanade/domain/category/model/Category.kt | 4 + .../category/repository/CategoryRepository.kt | 12 +- .../presentation/category/CategoryScreen.kt | 109 ++++++ .../category/components/CategoryContent.kt | 39 ++ .../category/components/CategoryDialogs.kt | 115 ++++++ .../CategoryFloatingActionButton.kt | 30 ++ .../category/components/CategoryListItem.kt | 63 +++ .../category/components/CategoryTopAppBar.kt | 33 ++ .../eu/kanade/presentation/util/Constants.kt | 10 +- .../tachiyomi/ui/category/CategoryAdapter.kt | 42 -- .../ui/category/CategoryController.kt | 359 +----------------- .../ui/category/CategoryCreateDialog.kt | 48 --- .../tachiyomi/ui/category/CategoryHolder.kt | 49 --- .../tachiyomi/ui/category/CategoryItem.kt | 73 ---- .../ui/category/CategoryPresenter.kt | 141 +++---- .../ui/category/CategoryRenameDialog.kt | 83 ---- .../main/res/layout/categories_controller.xml | 23 -- app/src/main/res/layout/categories_item.xml | 41 -- app/src/main/res/values/strings.xml | 5 + 27 files changed, 676 insertions(+), 859 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/category/interactor/CreateCategoryWithName.kt delete mode 100644 app/src/main/java/eu/kanade/domain/category/interactor/InsertCategory.kt create mode 100644 app/src/main/java/eu/kanade/domain/category/interactor/RenameCategory.kt create mode 100644 app/src/main/java/eu/kanade/domain/category/interactor/ReorderCategory.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/CategoryScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/CategoryContent.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/CategoryFloatingActionButton.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/CategoryTopAppBar.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt delete mode 100644 app/src/main/res/layout/categories_controller.xml delete mode 100644 app/src/main/res/layout/categories_item.xml diff --git a/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImpl.kt index d367089bf4..9c2c1283b0 100644 --- a/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImpl.kt @@ -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) { + 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 } - } } diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 7615d99992..21c5834238 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -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 { CategoryRepositoryImpl(get()) } addFactory { GetCategories(get()) } - addFactory { InsertCategory(get()) } + addFactory { CreateCategoryWithName(get()) } + addFactory { RenameCategory(get()) } + addFactory { ReorderCategory(get()) } addFactory { UpdateCategory(get()) } addFactory { DeleteCategory(get()) } diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/CreateCategoryWithName.kt b/app/src/main/java/eu/kanade/domain/category/interactor/CreateCategoryWithName.kt new file mode 100644 index 0000000000..d835634ba3 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/CreateCategoryWithName.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategory.kt index f44369ac24..52992fcf5f 100644 --- a/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategory.kt +++ b/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategory.kt @@ -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() } } diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategory.kt deleted file mode 100644 index 0a659d0e5d..0000000000 --- a/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategory.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/RenameCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/RenameCategory.kt new file mode 100644 index 0000000000..e2fbc4d7e2 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/RenameCategory.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/ReorderCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/ReorderCategory.kt new file mode 100644 index 0000000000..bfaaf174ac --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/ReorderCategory.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategory.kt index bff2a79025..939a87b6ba 100644 --- a/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategory.kt +++ b/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategory.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/domain/category/model/Category.kt b/app/src/main/java/eu/kanade/domain/category/model/Category.kt index 162df4e4fb..74666136e0 100644 --- a/app/src/main/java/eu/kanade/domain/category/model/Category.kt +++ b/app/src/main/java/eu/kanade/domain/category/model/Category.kt @@ -37,6 +37,10 @@ data class Category( } } +internal fun List.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() diff --git a/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepository.kt b/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepository.kt index 6499243b44..11055d5598 100644 --- a/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepository.kt +++ b/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepository.kt @@ -14,15 +14,11 @@ interface CategoryRepository { fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow> - @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) 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") diff --git a/app/src/main/java/eu/kanade/presentation/category/CategoryScreen.kt b/app/src/main/java/eu/kanade/presentation/category/CategoryScreen.kt new file mode 100644 index 0000000000..8fa1375531 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/CategoryScreen.kt @@ -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) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryContent.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryContent.kt new file mode 100644 index 0000000000..3b61957b8a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryContent.kt @@ -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, + 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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt new file mode 100644 index 0000000000..a400a39150 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt @@ -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)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryFloatingActionButton.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryFloatingActionButton.kt new file mode 100644 index 0000000000..e2c5d8762f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryFloatingActionButton.kt @@ -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(), + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt new file mode 100644 index 0000000000..ff0ed88076 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt @@ -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 = "") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryTopAppBar.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryTopAppBar.kt new file mode 100644 index 0000000000..490ed5115c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryTopAppBar.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Constants.kt b/app/src/main/java/eu/kanade/presentation/util/Constants.kt index da958b5d61..06b9c12143 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Constants.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Constants.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt deleted file mode 100644 index b9510868a1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt index c32ae9167f..c905db5f34 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt @@ -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(), - FabController, - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - CategoryAdapter.OnItemReleaseListener, - CategoryCreateDialog.Listener, - CategoryRenameDialog.Listener, - UndoHelper.OnActionListener { +class CategoryController : FullComposeController() { - /** - * 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) { - 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?) { - 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, + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt deleted file mode 100644 index 8134ee5017..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt deleted file mode 100644 index 9005229c5b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt deleted file mode 100644 index 7b6b3bf347..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt +++ /dev/null @@ -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() { - - /** - * 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>): 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>, - holder: CategoryHolder, - position: Int, - payloads: List?, - ) { - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt index b509b57288..66926bb07b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt @@ -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() { - private val _categories: MutableStateFlow> = 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 = 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) { + 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) { + 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() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt deleted file mode 100644 index a2946812b3..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt +++ /dev/null @@ -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(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" diff --git a/app/src/main/res/layout/categories_controller.xml b/app/src/main/res/layout/categories_controller.xml deleted file mode 100644 index 7e4f65d627..0000000000 --- a/app/src/main/res/layout/categories_controller.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/categories_item.xml b/app/src/main/res/layout/categories_item.xml deleted file mode 100644 index b28f574535..0000000000 --- a/app/src/main/res/layout/categories_item.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a78bf87f0..b063e5ac7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -849,4 +849,9 @@ Navigate to pan Zoom landscape image Unable to open last read chapter + Do you wish to delete the category %s + Delete category + Yes + No + InternalError: Check crash logs for further information