mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +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" | ||||
| @@ -1,23 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent"> | ||||
|  | ||||
|     <androidx.recyclerview.widget.RecyclerView | ||||
|         android:id="@+id/recycler" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" | ||||
|         android:choiceMode="multipleChoice" | ||||
|         android:clipToPadding="false" | ||||
|         android:paddingBottom="@dimen/fab_list_padding" | ||||
|         tools:listitem="@layout/categories_item" /> | ||||
|  | ||||
|     <eu.kanade.tachiyomi.widget.EmptyView | ||||
|         android:id="@+id/empty_view" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="center" | ||||
|         android:visibility="gone" /> | ||||
|  | ||||
| </FrameLayout> | ||||
| @@ -1,41 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/container" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     app:cardBackgroundColor="?android:attr/colorBackground" | ||||
|     app:cardElevation="0dp" | ||||
|     app:cardForegroundColor="@color/draggable_card_foreground"> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content"> | ||||
|  | ||||
|         <ImageView | ||||
|             android:id="@+id/reorder" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:padding="16dp" | ||||
|             android:scaleType="center" | ||||
|             app:srcCompat="@drawable/ic_drag_handle_24dp" | ||||
|             app:tint="?android:attr/textColorHint" | ||||
|             tools:ignore="ContentDescription" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/title" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_gravity="center_vertical" | ||||
|             android:layout_marginStart="16dp" | ||||
|             android:layout_marginEnd="16dp" | ||||
|             android:layout_weight="1" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="1" | ||||
|             android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|             tools:text="Category Title" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
|  | ||||
| </com.google.android.material.card.MaterialCardView> | ||||
| @@ -849,4 +849,9 @@ | ||||
|     <string name="pref_navigate_pan">Navigate to pan</string> | ||||
|     <string name="pref_landscape_zoom">Zoom landscape image</string> | ||||
|     <string name="cant_open_last_read_chapter">Unable to open last read chapter</string> | ||||
|     <string name="delete_category_confirmation">Do you wish to delete the category %s</string> | ||||
|     <string name="delete_category">Delete category</string> | ||||
|     <string name="yes">Yes</string> | ||||
|     <string name="no">No</string> | ||||
|     <string name="internal_error">InternalError: Check crash logs for further information</string> | ||||
| </resources> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user