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:
Andreas 2022-07-09 18:31:14 +02:00 committed by GitHub
parent 14a08f0668
commit 86bacbe586
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 676 additions and 859 deletions

View file

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

View file

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

View file

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

View file

@ -1,12 +1,43 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepository
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority
class DeleteCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(categoryId: Long) {
categoryRepository.delete(categoryId)
suspend fun await(categoryId: Long) = withContext(NonCancellable) await@{
try {
categoryRepository.delete(categoryId)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@await Result.InternalError(e)
}
val categories = categoryRepository.getAll()
val updates = categories.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartial(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
sealed class Result {
object Success : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

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

View file

@ -0,0 +1,43 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.model.anyWithName
import eu.kanade.domain.category.repository.CategoryRepository
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority
class RenameCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(categoryId: Long, name: String) = withContext(NonCancellable) await@{
val categories = categoryRepository.getAll()
if (categories.anyWithName(name)) {
return@await Result.NameAlreadyExistsError
}
val update = CategoryUpdate(
id = categoryId,
name = name,
)
try {
categoryRepository.updatePartial(update)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
suspend fun await(category: Category, name: String) = await(category.id, name)
sealed class Result {
object Success : Result()
object NameAlreadyExistsError : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -0,0 +1,51 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepository
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority
class ReorderCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(categoryId: Long, newPosition: Int) = withContext(NonCancellable) await@{
val categories = categoryRepository.getAll()
val currentIndex = categories.indexOfFirst { it.id == categoryId }
if (currentIndex == newPosition) {
return@await Result.Unchanged
}
val reorderedCategories = categories.toMutableList()
val reorderedCategory = reorderedCategories.removeAt(currentIndex)
reorderedCategories.add(newPosition, reorderedCategory)
val updates = reorderedCategories.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartial(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
suspend fun await(category: Category, newPosition: Long): Result =
await(category.id, newPosition.toInt())
sealed class Result {
object Success : Result()
object Unchanged : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
package eu.kanade.presentation.category.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import eu.kanade.domain.category.model.Category
@Composable
fun CategoryContent(
categories: List<Category>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onMoveUp: (Category) -> Unit,
onMoveDown: (Category) -> Unit,
onRename: (Category) -> Unit,
onDelete: (Category) -> Unit,
) {
LazyColumn(
state = lazyListState,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(categories) { index, category ->
CategoryListItem(
category = category,
canMoveUp = index != 0,
canMoveDown = index != categories.lastIndex,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onRename = onRename,
onDelete = onDelete,
)
}
}
}

View file

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

View file

@ -0,0 +1,30 @@
package eu.kanade.presentation.category.components
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.util.isScrolledToEnd
import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.tachiyomi.R
@Composable
fun CategoryFloatingActionButton(
lazyListState: LazyListState,
onCreate: () -> Unit,
) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(id = R.string.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = "") },
onClick = onCreate,
modifier = Modifier
.navigationBarsPadding(),
expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(),
)
}

View file

@ -0,0 +1,63 @@
package eu.kanade.presentation.category.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowDropUp
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.util.horizontalPadding
@Composable
fun CategoryListItem(
category: Category,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: (Category) -> Unit,
onMoveDown: (Category) -> Unit,
onRename: (Category) -> Unit,
onDelete: (Category) -> Unit,
) {
ElevatedCard {
Row(
modifier = Modifier
.padding(start = horizontalPadding, top = horizontalPadding, end = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = Icons.Outlined.Label, contentDescription = "")
Text(text = category.name, modifier = Modifier.padding(start = horizontalPadding))
}
Row {
IconButton(
onClick = { onMoveUp(category) },
enabled = canMoveUp,
) {
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
}
IconButton(
onClick = { onMoveDown(category) },
enabled = canMoveDown,
) {
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = { onRename(category) }) {
Icon(imageVector = Icons.Outlined.Edit, contentDescription = "")
}
IconButton(onClick = { onDelete(category) }) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
}
}
}
}

View file

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

View file

@ -3,6 +3,12 @@ package eu.kanade.presentation.util
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp
val horizontalPadding = 16.dp
private val horizontal = 16.dp
val topPaddingValues = PaddingValues(top = 8.dp)
private val vertical = 8.dp
val horizontalPadding = horizontal
val verticalPadding = vertical
val topPaddingValues = PaddingValues(top = vertical)

View file

@ -1,42 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import eu.davidea.flexibleadapter.FlexibleAdapter
/**
* Custom adapter for categories.
*
* @param controller The containing controller.
*/
class CategoryAdapter(controller: CategoryController) :
FlexibleAdapter<CategoryItem>(null, controller, true) {
/**
* Listener called when an item of the list is released.
*/
val onItemReleaseListener: OnItemReleaseListener = controller
/**
* Clears the active selections from the list and the model.
*/
override fun clearSelection() {
super.clearSelection()
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
}
/**
* Toggles the selection of the given position.
*
* @param position The position to toggle.
*/
override fun toggleSelection(position: Int) {
super.toggleSelection(position)
getItem(position)?.isSelected = isSelected(position)
}
interface OnItemReleaseListener {
/**
* Called when an item of the list is released.
*/
fun onItemReleased(position: Int)
}
}

View file

@ -1,357 +1,18 @@
package eu.kanade.tachiyomi.ui.category
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.davidea.flexibleadapter.helpers.UndoHelper
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import eu.kanade.presentation.category.CategoryScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
/**
* Controller to manage the categories for the users' library.
*/
class CategoryController :
NucleusController<CategoriesControllerBinding, CategoryPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
CategoryAdapter.OnItemReleaseListener,
CategoryCreateDialog.Listener,
CategoryRenameDialog.Listener,
UndoHelper.OnActionListener {
class CategoryController : FullComposeController<CategoryPresenter>() {
/**
* Object used to show ActionMode toolbar.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing category items.
*/
private var adapter: CategoryAdapter? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Undo helper used for restoring a deleted category.
*/
private var undoHelper: UndoHelper? = null
/**
* Creates the presenter for this controller. Not to be manually called.
*/
override fun createPresenter() = CategoryPresenter()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_categories)
}
override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater)
/**
* Called after view inflation. Used to initialize the view.
*
* @param view The view of this controller.
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = CategoryAdapter(this@CategoryController)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true)
binding.recycler.adapter = adapter
adapter?.isHandleDragEnabled = true
adapter?.isPermanentDelete = false
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
viewScope.launch {
presenter.categories.collect {
setCategories(it.map(::CategoryItem))
}
}
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setText(R.string.action_add)
fab.setIconResource(R.drawable.ic_add_24dp)
fab.setOnClickListener {
CategoryCreateDialog(this@CategoryController).showDialog(router, null)
}
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
fab.setOnClickListener(null)
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
actionFab = null
}
/**
* Called when the view is being destroyed. Used to release references and remove callbacks.
*
* @param view The view of this controller.
*/
override fun onDestroyView(view: View) {
// Manually call callback to delete categories if required
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
undoHelper = null
actionMode = null
adapter = null
super.onDestroyView(view)
}
/**
* Called from the presenter when the categories are updated.
*
* @param categories The new list of categories to display.
*/
fun setCategories(categories: List<CategoryItem>) {
actionMode?.finish()
adapter?.updateDataSet(categories)
if (categories.isNotEmpty()) {
binding.emptyView.hide()
val selected = categories.filter { it.isSelected }
if (selected.isNotEmpty()) {
selected.forEach { onItemLongClick(categories.indexOf(it)) }
}
} else {
binding.emptyView.show(R.string.information_empty_category)
}
}
/**
* Called when action mode is first created. The menu supplied will be used to generate action
* buttons for the action mode.
*
* @param mode ActionMode being created.
* @param menu Menu used to populate action buttons.
* @return true if the action mode should be created, false if entering this mode should be
* aborted.
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate menu.
mode.menuInflater.inflate(R.menu.category_selection, menu)
// Enable adapter multi selection.
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
/**
* Called to refresh an action mode's action menu whenever it is invalidated.
*
* @param mode ActionMode being prepared.
* @param menu Menu used to populate action buttons.
* @return true if the menu or action mode was updated, false otherwise.
*/
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val adapter = adapter ?: return false
val count = adapter.selectedItemCount
mode.title = count.toString()
// Show edit button only when one item is selected
val editItem = mode.menu.findItem(R.id.action_edit)
editItem.isVisible = count == 1
return true
}
/**
* Called to report a user click on an action button.
*
* @param mode The current ActionMode.
* @param item The item that was clicked.
* @return true if this callback handled the event, false if the standard MenuItem invocation
* should continue.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val adapter = adapter ?: return false
when (item.itemId) {
R.id.action_delete -> {
undoHelper = UndoHelper(adapter, this)
undoHelper?.start(
adapter.selectedPositions,
(activity as? MainActivity)?.binding?.rootCoordinator!!,
R.string.snack_categories_deleted,
R.string.action_undo,
4000,
)
mode.finish()
}
R.id.action_edit -> {
// Edit selected category
if (adapter.selectedItemCount == 1) {
val position = adapter.selectedPositions.first()
val category = adapter.getItem(position)?.category
if (category != null) {
editCategory(category)
}
}
}
else -> return false
}
return true
}
/**
* Called when an action mode is about to be exited and destroyed.
*
* @param mode The current ActionMode being destroyed.
*/
override fun onDestroyActionMode(mode: ActionMode) {
// Reset adapter to single selection
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
actionMode = null
}
/**
* Called when an item in the list is clicked.
*
* @param position The position of the clicked item.
* @return true if this click should enable selection mode.
*/
override fun onItemClick(view: View, position: Int): Boolean {
// Check if action mode is initialized and selected item exist.
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
toggleSelection(position)
true
} else {
false
}
}
/**
* Called when an item in the list is long clicked.
*
* @param position The position of the clicked item.
*/
override fun onItemLongClick(position: Int) {
val activity = activity as? AppCompatActivity ?: return
// Check if action mode is initialized.
if (actionMode == null) {
// Initialize action mode
actionMode = activity.startSupportActionMode(this)
}
// Set item as selected
toggleSelection(position)
}
/**
* Toggle the selection state of an item.
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
*
* @param position The position of the item to toggle.
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
// Mark the position selected
adapter.toggleSelection(position)
if (adapter.selectedItemCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
/**
* Called when an item is released from a drag.
*
* @param position The position of the released item.
*/
override fun onItemReleased(position: Int) {
val adapter = adapter ?: return
val categories = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.category }
presenter.reorderCategories(categories)
}
/**
* Called when the undo action is clicked in the snackbar.
*
* @param action The action performed.
*/
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
adapter?.restoreDeletedItems()
undoHelper = null
}
/**
* Called when the time to restore the items expires.
*
* @param action The action performed.
* @param event The event that triggered the action
*/
override fun onActionConfirmed(action: Int, event: Int) {
val adapter = adapter ?: return
presenter.deleteCategories(adapter.deletedItems.map { it.category })
undoHelper = null
}
/**
* Show a dialog to let the user change the category name.
*
* @param category The category to be edited.
*/
private fun editCategory(category: Category) {
CategoryRenameDialog(this, category).showDialog(router)
}
/**
* Renames the given category with the given name.
*
* @param category The category to rename.
* @param name The new name of the category.
*/
override fun renameCategory(category: Category, name: String) {
presenter.renameCategory(category, name)
}
/**
* Creates a new category with the given name.
*
* @param name The name of the new category.
*/
override fun createCategory(name: String) {
presenter.createCategory(name)
}
/**
* Called from the presenter when a category with the given name already exists.
*/
fun onCategoryExistsError() {
activity?.toast(R.string.error_category_exists)
@Composable
override fun ComposeContent() {
CategoryScreen(
presenter = presenter,
navigateUp = router::popCurrentController,
)
}
}

View file

@ -1,48 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
/**
* Dialog to create a new category for the library.
*/
class CategoryCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : CategoryCreateDialog.Listener {
/**
* Name of the new category. Value updated with each input from the user.
*/
private var currentName = ""
constructor(target: T) : this() {
targetController = target
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_add_category)
.setTextInput(prefill = currentName) {
currentName = it
}
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.createCategory(currentName)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun createCategory(name: String)
}
}

View file

@ -1,49 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.view.View
import androidx.recyclerview.widget.ItemTouchHelper
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
/**
* Holder used to display category items.
*
* @param view The view used by category items.
* @param adapter The adapter containing this holder.
*/
class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHolder(view, adapter) {
private val binding = CategoriesItemBinding.bind(view)
init {
setDragHandleView(binding.reorder)
}
/**
* Binds this holder with the given category.
*
* @param category The category to bind.
*/
fun bind(category: Category) {
binding.title.text = category.name
}
/**
* Called when an item is released.
*
* @param position The position of the released item.
*/
override fun onItemReleased(position: Int) {
super.onItemReleased(position)
adapter.onItemReleaseListener.onItemReleased(position)
binding.container.isDragged = false
}
override fun onActionStateChanged(position: Int, actionState: Int) {
super.onActionStateChanged(position, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
binding.container.isDragged = true
}
}
}

View file

@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.R
/**
* Category item for a recycler view.
*/
class CategoryItem(val category: Category) : AbstractFlexibleItem<CategoryHolder>() {
/**
* Whether this item is currently selected.
*/
var isSelected = false
/**
* Returns the layout resource for this item.
*/
override fun getLayoutRes(): Int {
return R.layout.categories_item
}
/**
* Returns a new view holder for this item.
*
* @param view The view of this item.
* @param adapter The adapter of this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): CategoryHolder {
return CategoryHolder(view, adapter as CategoryAdapter)
}
/**
* Binds the given view holder with this item.
*
* @param adapter The adapter of this item.
* @param holder The holder to bind.
* @param position The position of this item in the adapter.
* @param payloads List of partial changes.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: CategoryHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(category)
}
/**
* Returns true if this item is draggable.
*/
override fun isDraggable(): Boolean {
return true
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is CategoryItem) {
return category.id == other.category.id
}
return false
}
override fun hashCode(): Int {
return category.id.hashCode()
}
}

View file

@ -1,130 +1,91 @@
package eu.kanade.tachiyomi.ui.category
import android.os.Bundle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.domain.category.interactor.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.InsertCategory
import eu.kanade.domain.category.interactor.UpdateCategory
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.ReorderCategory
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.DuplicateNameException
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import logcat.LogPriority
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [CategoryController]. Used to manage the categories of the library.
*/
class CategoryPresenter(
private val getCategories: GetCategories = Injekt.get(),
private val insertCategory: InsertCategory = Injekt.get(),
private val updateCategory: UpdateCategory = Injekt.get(),
private val createCategoryWithName: CreateCategoryWithName = Injekt.get(),
private val renameCategory: RenameCategory = Injekt.get(),
private val reorderCategory: ReorderCategory = Injekt.get(),
private val deleteCategory: DeleteCategory = Injekt.get(),
) : BasePresenter<CategoryController>() {
private val _categories: MutableStateFlow<List<Category>> = MutableStateFlow(listOf())
val categories = _categories.asStateFlow()
var dialog: Dialog? by mutableStateOf(null)
/**
* Called when the presenter is created.
*
* @param savedState The saved state of this presenter.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
val categories = getCategories.subscribe()
presenterScope.launchIO {
getCategories.subscribe()
.collectLatest { list ->
_categories.value = list
}
}
}
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
/**
* Creates and adds a new category to the database.
*
* @param name The name of the category to create.
*/
fun createCategory(name: String) {
presenterScope.launchIO {
val result = insertCategory.await(
name = name,
order = categories.value.map { it.order + 1L }.maxOrNull() ?: 0L,
)
when (result) {
is InsertCategory.Result.Success -> {}
is InsertCategory.Result.Error -> {
logcat(LogPriority.ERROR, result.error)
if (result.error is DuplicateNameException) {
launchUI { view?.onCategoryExistsError() }
}
}
when (createCategoryWithName.await(name)) {
is CreateCategoryWithName.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is CreateCategoryWithName.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
/**
* Deletes the given categories from the database.
*
* @param categories The list of categories to delete.
*/
fun deleteCategories(categories: List<Category>) {
fun deleteCategory(category: Category) {
presenterScope.launchIO {
categories.forEach { category ->
deleteCategory.await(category.id)
when (deleteCategory.await(category.id)) {
is DeleteCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
/**
* Reorders the given categories in the database.
*
* @param categories The list of categories to reorder.
*/
fun reorderCategories(categories: List<Category>) {
fun moveUp(category: Category) {
presenterScope.launchIO {
categories.forEachIndexed { order, category ->
updateCategory.await(
payload = CategoryUpdate(
id = category.id,
order = order.toLong(),
),
)
when (reorderCategory.await(category, category.order - 1)) {
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
fun moveDown(category: Category) {
presenterScope.launchIO {
when (reorderCategory.await(category, category.order + 1)) {
is ReorderCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
/**
* Renames a category.
*
* @param category The category to rename.
* @param name The new name of the category.
*/
fun renameCategory(category: Category, name: String) {
presenterScope.launchIO {
val result = updateCategory.await(
payload = CategoryUpdate(
id = category.id,
name = name,
),
)
when (result) {
is UpdateCategory.Result.Success -> {}
is UpdateCategory.Result.Error -> {
logcat(LogPriority.ERROR, result.error)
if (result.error is DuplicateNameException) {
launchUI { view?.onCategoryExistsError() }
}
}
when (renameCategory.await(category, name)) {
RenameCategory.Result.NameAlreadyExistsError -> _events.send(Event.CategoryWithNameAlreadyExists)
is RenameCategory.Result.InternalError -> _events.send(Event.InternalError)
else -> {}
}
}
}
sealed class Dialog {
object Create : Dialog()
data class Rename(val category: Category) : Dialog()
data class Delete(val category: Category) : Dialog()
}
sealed class Event {
object CategoryWithNameAlreadyExists : Event()
object InternalError : Event()
}
}

View file

@ -1,83 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
/**
* Dialog to rename an existing category of the library.
*/
class CategoryRenameDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : CategoryRenameDialog.Listener {
private var category: Category? = null
/**
* Name of the new category. Value updated with each input from the user.
*/
private var currentName = ""
constructor(target: T, category: Category) : this() {
targetController = target
this.category = category
currentName = category.name
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_rename_category)
.setTextInput(prefill = currentName) {
currentName = it
}
.setPositiveButton(android.R.string.ok) { _, _ -> onPositive() }
.setNegativeButton(android.R.string.cancel, null)
.create()
}
/**
* Called to save this Controller's state in the event that its host Activity is destroyed.
*
* @param outState The Bundle into which data should be saved
*/
override fun onSaveInstanceState(outState: Bundle) {
outState.putSerializable(CATEGORY_KEY, category)
super.onSaveInstanceState(outState)
}
/**
* Restores data that was saved in the [onSaveInstanceState] method.
*
* @param savedInstanceState The bundle that has data to be restored
*/
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
category = savedInstanceState.getSerializable(CATEGORY_KEY) as? Category
}
/**
* Called when the positive button of the dialog is clicked.
*/
private fun onPositive() {
val target = targetController as? Listener ?: return
val category = category ?: return
target.renameCategory(category, currentName)
}
interface Listener {
fun renameCategory(category: Category, name: String)
}
}
private const val CATEGORY_KEY = "CategoryRenameDialog.category"

View file

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

View file

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

View file

@ -849,4 +849,9 @@
<string name="pref_navigate_pan">Navigate to pan</string>
<string name="pref_landscape_zoom">Zoom landscape image</string>
<string name="cant_open_last_read_chapter">Unable to open last read chapter</string>
<string name="delete_category_confirmation">Do you wish to delete the category %s</string>
<string name="delete_category">Delete category</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="internal_error">InternalError: Check crash logs for further information</string>
</resources>