Move a few Dialogs to Compose (#7861)

* Move a few Dialogs to Compose

- Separating dialogs that are not needed in the PR for the move to Compose on the Browse Source screen
- ChangeMangaCategoriesDialog and AddDuplicateMangaDialog will be removed in the Browse Source screen PR

* Review changes
This commit is contained in:
Andreas 2022-08-26 14:57:28 +02:00 committed by GitHub
parent 4b9a6541d1
commit 2453d1a886
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 657 additions and 479 deletions

View file

@ -0,0 +1,55 @@
package eu.kanade.core.prefs
import androidx.compose.ui.state.ToggleableState
sealed class CheckboxState<T>(open val value: T) {
abstract fun next(): CheckboxState<T>
sealed class State<T>(override val value: T) : CheckboxState<T>(value) {
data class Checked<T>(override val value: T) : State<T>(value)
data class None<T>(override val value: T) : State<T>(value)
val isChecked: Boolean
get() = this is Checked
override fun next(): CheckboxState<T> {
return when (this) {
is Checked -> None(value)
is None -> Checked(value)
}
}
}
sealed class TriState<T>(override val value: T) : CheckboxState<T>(value) {
data class Include<T>(override val value: T) : TriState<T>(value)
data class Exclude<T>(override val value: T) : TriState<T>(value)
data class None<T>(override val value: T) : TriState<T>(value)
override fun next(): CheckboxState<T> {
return when (this) {
is Exclude -> None(value)
is Include -> Exclude(value)
is None -> Include(value)
}
}
fun asState(): ToggleableState {
return when (this) {
is Exclude -> ToggleableState.Indeterminate
is Include -> ToggleableState.On
is None -> ToggleableState.Off
}
}
}
}
inline fun <T> T.asCheckboxState(condition: (T) -> Boolean): CheckboxState.State<T> {
return if (condition(this)) {
CheckboxState.State.Checked(this)
} else {
CheckboxState.State.None(this)
}
}
inline fun <T> List<T>.mapAsCheckboxState(condition: (T) -> Boolean): List<CheckboxState.State<T>> {
return this.map { it.asCheckboxState(condition) }
}

View file

@ -0,0 +1,122 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
@Composable
fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>,
onDismissRequest: () -> Unit,
onEditCategories: () -> Unit,
onConfirm: (List<Long>, List<Long>) -> Unit,
) {
if (initialSelection.isEmpty()) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onEditCategories()
},
) {
Text(text = stringResource(id = R.string.action_edit_categories))
}
},
title = {
Text(text = stringResource(id = R.string.action_move_category))
},
text = {
Text(text = stringResource(id = R.string.information_empty_category_dialog))
},
)
return
}
var selection by remember { mutableStateOf(initialSelection) }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
Row {
TextButton(onClick = {
onDismissRequest()
onEditCategories()
},) {
Text(text = stringResource(id = R.string.action_edit))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
TextButton(
onClick = {
onDismissRequest()
onConfirm(
selection.filter { it is CheckboxState.State.Checked || it is CheckboxState.TriState.Include }.map { it.value.id },
selection.filter { it is CheckboxState.TriState.Exclude }.map { it.value.id },
)
},
) {
Text(text = stringResource(id = R.string.action_add))
}
}
},
title = {
Text(text = stringResource(id = R.string.action_move_category))
},
text = {
Column {
selection.forEach { checkbox ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
val onCheckboxChange: (CheckboxState<Category>) -> Unit = {
val index = selection.indexOf(it)
val mutableList = selection.toMutableList()
mutableList.removeAt(index)
mutableList.add(index, it.next())
selection = mutableList.toList()
}
when (checkbox) {
is CheckboxState.TriState -> {
TriStateCheckbox(
state = checkbox.asState(),
onClick = { onCheckboxChange(checkbox) },
)
}
is CheckboxState.State -> {
Checkbox(
checked = checkbox.isChecked,
onCheckedChange = { onCheckboxChange(checkbox) },
)
}
}
Text(
text = checkbox.value.visualName,
modifier = Modifier.padding(horizontal = horizontalPadding),
)
}
}
}
},
)
}

View file

@ -0,0 +1,78 @@
package eu.kanade.tachiyomi.ui.library
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.tachiyomi.R
@Composable
fun DeleteLibraryMangaDialog(
containsLocalManga: Boolean,
onDismissRequest: () -> Unit,
onConfirm: (Boolean, Boolean) -> Unit,
) {
var list by remember {
mutableStateOf(
buildList<CheckboxState.State<Int>> {
add(CheckboxState.State.None(R.string.manga_from_library))
if (!containsLocalManga) {
add(CheckboxState.State.None(R.string.downloaded_chapters))
}
},
)
}
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm(
list[0].isChecked,
list.getOrElse(1) { CheckboxState.State.None(0) }.isChecked,
)
},
) {
Text(text = stringResource(id = android.R.string.ok))
}
},
title = {
Text(text = stringResource(id = R.string.action_remove))
},
text = {
Column {
list.forEach { state ->
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.isChecked,
onCheckedChange = {
val index = list.indexOf(state)
val mutableList = list.toMutableList()
mutableList.removeAt(index)
mutableList.add(index, state.next() as CheckboxState.State<Int>)
list = mutableList.toList()
},
)
Text(text = stringResource(id = state.value))
}
}
}
},
)
}

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryPresenter
@Stable
interface LibraryState {
@ -16,6 +17,7 @@ interface LibraryState {
val selection: List<LibraryManga>
val selectionMode: Boolean
var hasActiveFilters: Boolean
var dialog: LibraryPresenter.Dialog?
}
fun LibraryState(): LibraryState {
@ -29,4 +31,5 @@ class LibraryStateImpl : LibraryState {
override var selection: List<LibraryManga> by mutableStateOf(emptyList())
override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() }
override var hasActiveFilters: Boolean by mutableStateOf(false)
override var dialog: LibraryPresenter.Dialog? by mutableStateOf(null)
}

View file

@ -0,0 +1,90 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ChevronLeft
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.KeyboardDoubleArrowLeft
import androidx.compose.material.icons.outlined.KeyboardDoubleArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import eu.kanade.tachiyomi.R
@Composable
fun DownloadCustomAmountDialog(
maxAmount: Int,
onDismissRequest: () -> Unit,
onConfirm: (Int) -> Unit,
) {
var amount by remember { mutableStateOf(0) }
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm(amount.coerceIn(0, maxAmount))
},
) {
Text(text = stringResource(id = android.R.string.ok))
}
},
title = {
Text(text = stringResource(id = R.string.custom_download))
},
text = {
val onChangeAmount: (Int) -> Unit = { amount = (amount + it).coerceIn(0, maxAmount) }
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { onChangeAmount(-10) },
enabled = amount > 10,
) {
Icon(imageVector = Icons.Outlined.KeyboardDoubleArrowLeft, contentDescription = "")
}
IconButton(
onClick = { onChangeAmount(-1) },
enabled = amount > 0,
) {
Icon(imageVector = Icons.Outlined.ChevronLeft, contentDescription = "")
}
BasicTextField(
value = amount.toString(),
onValueChange = { onChangeAmount(it.toIntOrNull() ?: 0) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
IconButton(
onClick = { onChangeAmount(1) },
enabled = amount < maxAmount,
) {
Icon(imageVector = Icons.Outlined.ChevronRight, contentDescription = "")
}
IconButton(
onClick = { onChangeAmount(10) },
enabled = amount < maxAmount,
) {
Icon(imageVector = Icons.Outlined.KeyboardDoubleArrowRight, contentDescription = "")
}
}
},
)
}

View file

@ -0,0 +1,39 @@
package eu.kanade.presentation.manga.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun DeleteChaptersDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(id = android.R.string.ok))
}
},
title = {
Text(text = stringResource(id = R.string.are_you_sure))
},
text = {
Text(text = stringResource(id = R.string.confirm_delete_chapters))
},
)
}

View file

@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : DeleteLibraryMangasDialog.Listener {
private var mangas = emptyList<Manga>()
constructor(target: T, mangas: List<Manga>) : this() {
this.mangas = mangas
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val canDeleteChapters = mangas.any { !it.isLocal() }
val items = when (canDeleteChapters) {
true -> listOf(
R.string.manga_from_library,
R.string.downloaded_chapters,
)
false -> listOf(R.string.manga_from_library)
}
.map { resources!!.getString(it) }
.toTypedArray()
val selected = items
.map { false }
.toBooleanArray()
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_remove)
.setMultiChoiceItems(items, selected) { _, which, checked ->
selected[which] = checked
}
.setPositiveButton(android.R.string.ok) { _, _ ->
val deleteFromLibrary = selected[0]
val deleteChapters = canDeleteChapters && selected[1]
(targetController as? Listener)?.deleteMangas(mangas, deleteFromLibrary, deleteChapters)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean)
}
}

View file

@ -8,9 +8,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import eu.kanade.domain.category.model.Category
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.library.LibraryScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
@ -19,20 +21,16 @@ import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import kotlinx.coroutines.cancel
class LibraryController(
bundle: Bundle? = null,
) : FullComposeController<LibraryPresenter>(bundle),
RootController,
ChangeMangaCategoriesDialog.Listener,
DeleteLibraryMangasDialog.Listener {
) : FullComposeController<LibraryPresenter>(bundle), RootController {
/**
* Sheet containing filter/sort/display items.
@ -65,6 +63,36 @@ class LibraryController(
onClickSelectAll = { presenter.selectAll(presenter.activeCategory) },
onClickUnselectAll = ::clearSelection,
)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is LibraryPresenter.Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
presenter.clearSelection()
router.pushController(CategoryController())
},
onConfirm = { include, exclude ->
presenter.clearSelection()
presenter.setMangaCategories(dialog.manga, include, exclude)
},
)
}
is LibraryPresenter.Dialog.DeleteManga -> {
DeleteLibraryMangaDialog(
containsLocalManga = dialog.manga.any(Manga::isLocal),
onDismissRequest = onDismissRequest,
onConfirm = { deleteManga, deleteChapter ->
presenter.removeMangas(dialog.manga.map { it.toDbManga() }, deleteManga, deleteChapter)
presenter.clearSelection()
},
)
}
null -> {}
}
LaunchedEffect(presenter.selectionMode) {
val activity = (activity as? MainActivity) ?: return@LaunchedEffect
// Could perhaps be removed when navigation is in a Compose world
@ -169,53 +197,40 @@ class LibraryController(
private fun showMangaCategoriesDialog() {
viewScope.launchIO {
// Create a copy of selected manga
val mangas = presenter.selection.toList()
val mangaList = presenter.selection.mapNotNull { it.toDomainManga() }.toList()
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0L }
// Get indexes of the common categories to preselect.
val common = presenter.getCommonCategories(mangas.mapNotNull { it.toDomainManga() })
val common = presenter.getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = presenter.getMixCategories(mangas.mapNotNull { it.toDomainManga() })
val mix = presenter.getMixCategories(mangaList)
val preselected = categories.map {
when (it) {
in common -> QuadStateTextView.State.CHECKED.ordinal
in mix -> QuadStateTextView.State.INDETERMINATE.ordinal
else -> QuadStateTextView.State.UNCHECKED.ordinal
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}.toTypedArray()
withUIContext {
ChangeMangaCategoriesDialog(this@LibraryController, mangas.mapNotNull { it.toDomainManga() }, categories, preselected)
.showDialog(router)
}
presenter.dialog = LibraryPresenter.Dialog.ChangeCategory(mangaList, preselected)
}
}
private fun downloadUnreadChapters() {
val mangas = presenter.selection.toList()
presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
val mangaList = presenter.selection.toList()
presenter.downloadUnreadChapters(mangaList.mapNotNull { it.toDomainManga() })
presenter.clearSelection()
}
private fun markReadStatus(read: Boolean) {
val mangas = presenter.selection.toList()
presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
val mangaList = presenter.selection.toList()
presenter.markReadStatus(mangaList.mapNotNull { it.toDomainManga() }, read)
presenter.clearSelection()
}
private fun showDeleteMangaDialog() {
val mangas = presenter.selection.toList()
DeleteLibraryMangasDialog(this, mangas.mapNotNull { it.toDomainManga() }).showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
presenter.setMangaCategories(mangas, addCategories, removeCategories)
presenter.clearSelection()
}
override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters)
presenter.clearSelection()
val mangaList = presenter.selection.mapNotNull { it.toDomainManga() }.toList()
presenter.dialog = LibraryPresenter.Dialog.DeleteManga(mangaList)
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.util.fastAny
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.core.util.asFlow
import eu.kanade.core.util.asObservable
@ -610,13 +611,15 @@ class LibraryPresenter(
* @param addCategories the categories to add for all mangas.
* @param removeCategories the categories to remove in all mangas.
*/
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
presenterScope.launchIO {
mangaList.map { manga ->
val categoryIds = getCategories.await(manga.id)
.map { it.id }
.subtract(removeCategories)
.plus(addCategories)
.map { it.id }
.toList()
setMangaCategories.await(manga.id, categoryIds)
}
}
@ -715,4 +718,9 @@ class LibraryPresenter(
val items = (loadedManga[category.id] ?: emptyList()).map { it.manga }
state.selection = items.filterNot { it in selection }
}
sealed class Dialog {
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog()
data class DeleteManga(val manga: List<Manga>) : Dialog()
}
}

View file

@ -2,10 +2,19 @@ package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.os.Bundle
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.pushController
@ -46,3 +55,48 @@ class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle)
.create()
}
}
@Composable
fun DuplicateDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
onOpenManga: () -> Unit,
duplicateFrom: Source,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
Row {
TextButton(onClick = {
onDismissRequest()
onOpenManga()
},) {
Text(text = stringResource(id = R.string.action_show_manga))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(id = R.string.action_add))
}
}
},
title = {
Text(text = stringResource(id = R.string.are_you_sure))
},
text = {
Text(
text = stringResource(
id = R.string.confirm_manga_add_duplicate,
duplicateFrom.name,
),
)
},
)
}

View file

@ -6,26 +6,26 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.os.bundleOf
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.util.calculateWindowWidthSizeClass
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
@ -41,11 +41,12 @@ import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaPresenter.Dialog
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSettingsSheet
import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomChaptersDialog
import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomAmountDialog
import eu.kanade.tachiyomi.ui.manga.info.MangaFullCoverDialog
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog
@ -54,21 +55,13 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import eu.kanade.tachiyomi.widget.materialdialogs.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
class MangaController :
FullComposeController<MangaPresenter>,
ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener {
class MangaController : FullComposeController<MangaPresenter> {
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
@ -112,9 +105,19 @@ class MangaController :
@Composable
override fun ComposeContent() {
val state by presenter.state.collectAsState()
val dialog by derivedStateOf {
when (val state = state) {
MangaScreenState.Loading -> null
is MangaScreenState.Success -> state.dialog
}
}
if (state is MangaScreenState.Success) {
val successState = state as MangaScreenState.Success
val isHttpSource = remember { successState.source is HttpSource }
val scope = rememberCoroutineScope()
MangaScreen(
state = successState,
snackbarHostState = snackbarHostState,
@ -133,16 +136,67 @@ class MangaController :
onCoverClicked = this::openCoverDialog,
onShareClicked = this::shareManga.takeIf { isHttpSource },
onDownloadActionClicked = this::runDownloadChapterAction.takeIf { !successState.source.isLocalOrStub() },
onEditCategoryClicked = this::onCategoriesClick.takeIf { successState.manga.favorite },
onEditCategoryClicked = presenter::promptChangeCategories.takeIf { successState.manga.favorite },
onMigrateClicked = this::migrateManga.takeIf { successState.manga.favorite },
onMultiBookmarkClicked = presenter::bookmarkChapters,
onMultiMarkAsReadClicked = presenter::markChaptersRead,
onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead,
onMultiDeleteClicked = this::deleteChaptersWithConfirmation,
onMultiDeleteClicked = presenter::showDeleteChapterDialog,
onChapterSelected = presenter::toggleSelection,
onAllChapterSelected = presenter::toggleAllSelection,
onInvertSelection = presenter::invertSelection,
)
val onDismissRequest = { presenter.dismissDialog() }
when (val dialog = dialog) {
is Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include)
},
)
}
is Dialog.DeleteChapters -> {
DeleteChaptersDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
deleteChapters(dialog.chapters)
},
)
}
is Dialog.DownloadCustomAmount -> {
DownloadCustomAmountDialog(
maxAmount = dialog.max,
onDismissRequest = onDismissRequest,
onConfirm = { amount ->
val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
scope.launch { downloadChapters(chaptersToDownload) }
}
},
)
}
is Dialog.DuplicateManga -> {
DuplicateDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.toggleFavorite(
onRemoved = {},
onAdded = {},
checkDuplicate = false,
)
},
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
)
}
null -> {}
}
} else {
LoadingScreen()
}
@ -206,30 +260,10 @@ class MangaController :
}
}
private fun onFavoriteClick(checkDuplicate: Boolean = true) {
private fun onFavoriteClick() {
presenter.toggleFavorite(
onRemoved = this::onFavoriteRemoved,
onAdded = { activity?.toast(activity?.getString(R.string.manga_added_library)) },
onDuplicateExists = if (checkDuplicate) {
{
AddDuplicateMangaDialog(
target = this,
libraryManga = it,
onAddToLibrary = { onFavoriteClick(checkDuplicate = false) },
).showDialog(router)
}
} else null,
onRequireCategory = { manga, categories ->
val ids = runBlocking { presenter.getMangaCategoryIds(manga) }
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
showChangeCategoryDialog(manga, categories, preselected)
},
)
}
@ -249,40 +283,6 @@ class MangaController :
}
}
private fun onCategoriesClick() {
viewScope.launchIO {
val manga = presenter.manga ?: return@launchIO
val categories = presenter.getCategories()
val ids = presenter.getMangaCategoryIds(manga)
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
withUIContext {
showChangeCategoryDialog(manga, categories, preselected)
}
}
}
private fun showChangeCategoryDialog(manga: Manga, categories: List<Category>, preselected: Array<Int>) {
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
override fun updateCategoriesForMangas(
mangas: List<Manga>,
addCategories: List<Category>,
removeCategories: List<Category>,
) {
val changed = mangas.firstOrNull() ?: return
presenter.moveMangaToCategoriesAndAddToLibrary(changed, addCategories)
}
/**
* Perform a search using the provided query.
*
@ -427,15 +427,6 @@ class MangaController :
}
}
private fun deleteChaptersWithConfirmation(chapters: List<DomainChapter>) {
viewScope.launch {
val result = MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.await(android.R.string.ok, android.R.string.cancel)
if (result == AlertDialog.BUTTON_POSITIVE) deleteChapters(chapters)
}
}
fun deleteChapters(chapters: List<DomainChapter>) {
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
@ -449,7 +440,7 @@ class MangaController :
DownloadAction.NEXT_5_CHAPTERS -> presenter.getUnreadChaptersSorted().take(5)
DownloadAction.NEXT_10_CHAPTERS -> presenter.getUnreadChaptersSorted().take(10)
DownloadAction.CUSTOM -> {
showCustomDownloadDialog()
presenter.showDownloadCustomDialog()
return
}
DownloadAction.UNREAD_CHAPTERS -> presenter.getUnreadChapters()
@ -462,21 +453,6 @@ class MangaController :
}
}
private fun showCustomDownloadDialog() {
val availableChapters = presenter.processedChapters?.count() ?: return
DownloadCustomChaptersDialog(
this,
availableChapters,
).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
viewScope.launch { downloadChapters(chaptersToDownload) }
}
}
// Chapters list - end
// Tracker sheet - start

View file

@ -4,6 +4,8 @@ import android.app.Application
import android.content.Context
import android.os.Bundle
import androidx.compose.runtime.Immutable
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.mapAsCheckboxState
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.category.model.Category
@ -61,6 +63,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority
@ -78,6 +81,7 @@ class MangaPresenter(
val isFromSource: Boolean,
private val preferences: PreferencesHelper = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(),
private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
@ -182,6 +186,7 @@ class MangaPresenter(
isRefreshingChapter = true,
isIncognitoMode = incognitoMode,
isDownloadedOnlyMode = downloadedOnlyMode,
dialog = null,
)
}
@ -259,8 +264,7 @@ class MangaPresenter(
fun toggleFavorite(
onRemoved: () -> Unit,
onAdded: () -> Unit,
onRequireCategory: (manga: DomainManga, availableCats: List<Category>) -> Unit,
onDuplicateExists: ((DomainManga) -> Unit)?,
checkDuplicate: Boolean = true,
) {
val state = successState ?: return
presenterScope.launchIO {
@ -278,10 +282,16 @@ class MangaPresenter(
} else {
// Add to library
// First, check if duplicate exists if callback is provided
if (onDuplicateExists != null) {
if (checkDuplicate) {
val duplicate = getDuplicateLibraryManga.await(manga.title, manga.source)
if (duplicate != null) {
withUIContext { onDuplicateExists(duplicate) }
_state.update { state ->
when (state) {
MangaScreenState.Loading -> state
is MangaScreenState.Success -> state.copy(dialog = Dialog.DuplicateManga(manga, duplicate))
}
}
return@launchIO
}
}
@ -308,7 +318,7 @@ class MangaPresenter(
}
// Choose a category
else -> withUIContext { onRequireCategory(manga, categories) }
else -> promptChangeCategories()
}
// Finally match with enhanced tracking when available
@ -334,6 +344,26 @@ class MangaPresenter(
}
}
fun promptChangeCategories() {
val state = successState ?: return
val manga = state.manga
presenterScope.launch {
val categories = getCategories()
val selection = getMangaCategoryIds(manga)
_state.update { state ->
when (state) {
MangaScreenState.Loading -> state
is MangaScreenState.Success -> state.copy(
dialog = Dialog.ChangeCategory(
manga = manga,
initialSelection = categories.mapAsCheckboxState { it.id in selection },
),
)
}
}
}
}
/**
* Returns true if the manga has any downloads.
*/
@ -365,13 +395,13 @@ class MangaPresenter(
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
suspend fun getMangaCategoryIds(manga: DomainManga): Array<Long> {
val categories = getCategories.await(manga.id)
return categories.map { it.id }.toTypedArray()
suspend fun getMangaCategoryIds(manga: DomainManga): List<Long> {
return getCategories.await(manga.id)
.map { it.id }
}
fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List<Category>) {
moveMangaToCategories(categories)
fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List<Long>) {
moveMangaToCategory(categories)
if (!manga.favorite) {
presenterScope.launchIO {
updateManga.awaitUpdateFavorite(manga.id, true)
@ -387,6 +417,10 @@ class MangaPresenter(
*/
private fun moveMangaToCategories(categories: List<Category>) {
val categoryIds = categories.map { it.id }
moveMangaToCategory(categoryIds)
}
fun moveMangaToCategory(categoryIds: List<Long>) {
presenterScope.launchIO {
setMangaCategories.await(mangaId, categoryIds)
}
@ -994,6 +1028,45 @@ class MangaPresenter(
}
// Track sheet - end
fun getSourceOrStub(manga: DomainManga): Source {
return sourceManager.getOrStub(manga.source)
}
sealed class Dialog {
data class ChangeCategory(val manga: DomainManga, val initialSelection: List<CheckboxState<Category>>) : Dialog()
data class DeleteChapters(val chapters: List<DomainChapter>) : Dialog()
data class DuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog()
data class DownloadCustomAmount(val max: Int) : Dialog()
}
fun dismissDialog() {
_state.update { state ->
when (state) {
MangaScreenState.Loading -> state
is MangaScreenState.Success -> state.copy(dialog = null)
}
}
}
fun showDownloadCustomDialog() {
val max = processedChapters?.count() ?: return
_state.update { state ->
when (state) {
MangaScreenState.Loading -> state
is MangaScreenState.Success -> state.copy(dialog = Dialog.DownloadCustomAmount(max))
}
}
}
fun showDeleteChapterDialog(chapters: List<DomainChapter>) {
_state.update { state ->
when (state) {
MangaScreenState.Loading -> state
is MangaScreenState.Success -> state.copy(dialog = Dialog.DeleteChapters(chapters))
}
}
}
}
sealed class MangaScreenState {
@ -1012,6 +1085,7 @@ sealed class MangaScreenState {
val isRefreshingChapter: Boolean = false,
val isIncognitoMode: Boolean = false,
val isDownloadedOnlyMode: Boolean = false,
val dialog: MangaPresenter.Dialog? = null,
) : MangaScreenState() {
val processedChapters: Sequence<ChapterItem>

View file

@ -1,75 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
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.DialogCustomDownloadView
/**
* Dialog used to let user select amount of chapters to download.
*/
class DownloadCustomChaptersDialog<T> : DialogController
where T : Controller, T : DownloadCustomChaptersDialog.Listener {
/**
* Maximum number of chapters to download in download chooser.
*/
private val maxChapters: Int
/**
* Initialize dialog.
* @param maxChapters maximal number of chapters that user can download.
*/
constructor(target: T, maxChapters: Int) : super(
// Add maximum number of chapters to download value to bundle.
bundleOf(KEY_ITEM_MAX to maxChapters),
) {
targetController = target
this.maxChapters = maxChapters
}
/**
* Restore dialog.
* @param bundle bundle containing data from state restore.
*/
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
// Get maximum chapters to download from bundle
val maxChapters = bundle.getInt(KEY_ITEM_MAX, 0)
this.maxChapters = maxChapters
}
/**
* Called when dialog is being created.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
// Initialize view that lets user select number of chapters to download.
val view = DialogCustomDownloadView(activity).apply {
setMinMax(0, maxChapters)
}
// Build dialog.
// when positive dialog is pressed call custom listener.
return MaterialAlertDialogBuilder(activity)
.setTitle(R.string.custom_download)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.downloadCustomChapters(view.amount)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun downloadCustomChapters(amount: Int)
}
}
// Key to retrieve max chapters from bundle on process death.
private const val KEY_ITEM_MAX = "DownloadCustomChaptersDialog.int.maxChapters"

View file

@ -1,125 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.text.InputFilter
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.core.text.isDigitsOnly
import androidx.core.widget.doOnTextChanged
import eu.kanade.tachiyomi.databinding.DownloadCustomAmountBinding
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
/**
* Custom dialog to select how many chapters to download.
*/
class DialogCustomDownloadView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
LinearLayout(context, attrs) {
/**
* Current amount of custom download chooser.
*/
var amount: Int = 0
private set
/**
* Minimal value of custom download chooser.
*/
private var min = 0
/**
* Maximal value of custom download chooser.
*/
private var max = 0
private val binding: DownloadCustomAmountBinding
init {
binding = DownloadCustomAmountBinding.inflate(LayoutInflater.from(context), this, false)
addView(binding.root)
}
override fun onViewAdded(child: View) {
super.onViewAdded(child)
// Set download count to 0.
binding.myNumber.text = SpannableStringBuilder(getAmount(0).toString())
binding.myNumber.filters = arrayOf(DigitInputFilter())
// When user presses button decrease amount by 10.
binding.btnDecrease10.setOnClickListener {
binding.myNumber.text = SpannableStringBuilder(getAmount(amount - 10).toString())
}
// When user presses button increase amount by 10.
binding.btnIncrease10.setOnClickListener {
binding.myNumber.text = SpannableStringBuilder(getAmount(amount + 10).toString())
}
// When user presses button decrease amount by 1.
binding.btnDecrease.setOnClickListener {
binding.myNumber.text = SpannableStringBuilder(getAmount(amount - 1).toString())
}
// When user presses button increase amount by 1.
binding.btnIncrease.setOnClickListener {
binding.myNumber.text = SpannableStringBuilder(getAmount(amount + 1).toString())
}
// When user inputs custom number set amount equal to input.
binding.myNumber.doOnTextChanged { text, _, _, _ ->
try {
amount = getAmount(text.toString().toInt())
} catch (error: NumberFormatException) {
// Catch NumberFormatException to prevent parse exception when input is empty.
logcat(LogPriority.ERROR, error)
}
}
}
/**
* Set min max of custom download amount chooser.
* @param min minimal downloads
* @param max maximal downloads
*/
fun setMinMax(min: Int, max: Int) {
this.min = min
this.max = max
}
/**
* Returns amount to download.
* if minimal downloads is less than input return minimal downloads.
* if Maximal downloads is more than input return maximal downloads.
*
* @return amount to download.
*/
private fun getAmount(input: Int): Int {
return when {
input > max -> max
input < min -> min
else -> input
}
}
}
private class DigitInputFilter : InputFilter {
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int,
): CharSequence {
return when {
source.toString().isDigitsOnly() -> source.toString()
else -> ""
}
}
}

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M18.41,7.41L17,6L11,12L17,18L18.41,16.59L13.83,12L18.41,7.41M12.41,7.41L11,6L5,12L11,18L12.41,16.59L7.83,12L12.41,7.41Z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M5.59,7.41L7,6L13,12L7,18L5.59,16.59L10.17,12L5.59,7.41M11.59,7.41L13,6L19,12L13,18L11.59,16.59L16.17,12L11.59,7.41Z" />
</vector>

View file

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingVertical="8dp">
<com.google.android.material.button.MaterialButton
style="@style/Widget.Tachiyomi.Button.IconButton"
android:id="@+id/btn_decrease_10"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_chevron_left_double_black_24dp" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.Tachiyomi.Button.IconButton"
android:id="@+id/btn_decrease"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_chevron_left_black_24dp" />
<eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText
android:id="@+id/myNumber"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:digits="0123456789"
android:inputType="number"
android:padding="8dp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.Tachiyomi.Button.IconButton"
android:id="@+id/btn_increase"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_chevron_right_black_24dp" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.Tachiyomi.Button.IconButton"
android:id="@+id/btn_increase_10"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_chevron_right_double_black_24dp" />
</LinearLayout>

View file

@ -653,6 +653,7 @@
<string name="also_set_chapter_settings_for_library">Also apply to all manga in my library</string>
<string name="set_chapter_settings_as_default">Set as default</string>
<string name="no_chapters_error">No chapters found</string>
<string name="are_you_sure">Are you sure?</string>
<!-- Tracking Screen -->
<string name="tracker_anilist" translatable="false">AniList</string>