implement subcategory

This commit is contained in:
Gfadebayo 2023-08-19 18:32:31 +01:00
parent d1c956401c
commit 48648d1804
31 changed files with 779 additions and 61 deletions

View File

@ -27,7 +27,9 @@ import tachiyomi.data.source.StubSourceRepositoryImpl
import tachiyomi.data.track.TrackRepositoryImpl
import tachiyomi.data.updates.UpdatesRepositoryImpl
import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.CreateSubcategory
import tachiyomi.domain.category.interactor.DeleteCategory
import tachiyomi.domain.category.interactor.DeleteSubcategory
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.RenameCategory
import tachiyomi.domain.category.interactor.ReorderCategory
@ -91,6 +93,8 @@ class DomainModule : InjektModule {
addFactory { ReorderCategory(get()) }
addFactory { UpdateCategory(get()) }
addFactory { DeleteCategory(get()) }
addFactory { CreateSubcategory(get()) }
addFactory { DeleteSubcategory(get()) }
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
addFactory { GetDuplicateLibraryManga(get()) }

View File

@ -75,7 +75,7 @@ fun TabbedScreen(
Tab(
selected = state.currentPage == index,
onClick = { scope.launch { state.animateScrollToPage(index) } },
text = { TabText(text = stringResource(tab.titleRes), badgeCount = tab.badgeNumber) },
text = { TabText(text = stringResource(tab.titleRes), badgeText = tab.badgeNumber?.toString()) },
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}

View File

@ -0,0 +1,76 @@
package eu.kanade.presentation.library
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.R
import kotlinx.coroutines.delay
import tachiyomi.domain.category.model.Category
import kotlin.time.Duration.Companion.seconds
@Composable
fun CreateSubcategoryDialog(
categories: List<Category>,
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.fastAny { it.name == name } }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
enabled = name.isNotEmpty() && !nameAlreadyExists,
onClick = {
onCreate(name)
onDismissRequest()
},
) {
Text(text = stringResource(R.string.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
},
title = {
Text(text = stringResource(R.string.action_add_subcategory))
},
text = {
OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(R.string.name)) },
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) R.string.error_subcategory_exists else R.string.information_required_plain
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true,
)
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}

View File

@ -2,17 +2,23 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
@ -29,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -105,6 +112,31 @@ fun MangaCompactGridItem(
}
}
@Composable
fun MangaCompactSubcategoryItem(
title: String? = null,
covers: List<tachiyomi.domain.manga.model.MangaCover>,
remainingCount: Int,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
SubcategoryGridCover(
modifier = modifier,
covers = covers,
remainingCount = remainingCount,
) {
if (title != null) CoverTextOverlay(title = title)
}
}
}
/**
* Title overlay for [MangaCompactGridItem]
*/
@ -211,6 +243,36 @@ fun MangaComfortableGridItem(
}
}
@Composable
fun MangaComfortableSubcategoryItem(
title: String,
titleMaxLines: Int = 2,
covers: List<tachiyomi.domain.manga.model.MangaCover>,
remainingCount: Int,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
Column {
SubcategoryGridCover(modifier = modifier, covers = covers, remainingCount = remainingCount)
GridItemTitle(
modifier = Modifier.padding(4.dp),
title = title,
style = MaterialTheme.typography.titleSmall,
minLines = 2,
maxLines = titleMaxLines,
)
}
}
}
/**
* Common cover layout to add contents to be drawn on top of the cover.
*/
@ -249,6 +311,63 @@ private fun MangaGridCover(
}
}
@Composable
private fun SubcategoryGridCover(
modifier: Modifier = Modifier,
covers: List<tachiyomi.domain.manga.model.MangaCover>,
remainingCount: Int,
content: (@Composable BoxScope.() -> Unit)? = null,
) {
MangaGridCover(
modifier,
cover = {
if (covers.size <= 2) {
Column(modifier = Modifier.height(IntrinsicSize.Min)) {
MangaCover.Book(
data = covers[0],
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.weight(1f)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.requiredHeight(4.dp))
if (covers.size == 2) {
MangaCover.Book(
data = covers[1],
modifier = Modifier
.clip(MaterialTheme.shapes.small.copy(bottomEnd = CornerSize(4.dp), bottomStart = CornerSize(4.dp)))
.weight(1f)
.fillMaxWidth(),
)
}
}
} else {
FlowRow(
modifier = Modifier
.clipToBounds()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
maxItemsInEachRow = 2,
) {
for (cover in covers) {
MangaCover.Book(
data = cover,
modifier = Modifier.weight(1f),
)
}
}
}
},
badgesEnd = {
if (remainingCount > 0) SubcategoryItemCountBadge(count = remainingCount)
},
content = content,
)
}
@Composable
private fun GridItemTitle(
modifier: Modifier,

View File

@ -47,6 +47,15 @@ internal fun LanguageBadge(
}
}
@Composable
internal fun SubcategoryItemCountBadge(count: Int) {
Badge(
text = "+$count",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
@ThemePreviews
@Composable
private fun BadgePreview() {

View File

@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.manga.model.asMangaCover
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
@Composable
internal fun LibraryComfortableGrid(
items: List<LibraryItem>,
subcategories: List<Pair<Category, List<LibraryItem>>>,
columns: Int,
contentPadding: PaddingValues,
selection: List<LibraryManga>,
@ -29,6 +34,23 @@ internal fun LibraryComfortableGrid(
) {
globalSearchItem(searchQuery, onGlobalSearchClicked)
items(
items = subcategories,
contentType = { "library_comfortable_grid_subcategory" },
) { (category, libraryItem) ->
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
MangaComfortableSubcategoryItem(
title = category.name,
covers = covers,
remainingCount = remainingCount,
isSelected = if (selection.isNotEmpty()) selection.contains(libraryItem[0].libraryManga) else false,
onClick = { onClick(libraryItem[0].libraryManga) },
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
)
}
items(
items = items,
contentType = { "library_comfortable_grid_item" },

View File

@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.manga.model.asMangaCover
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
@Composable
internal fun LibraryCompactGrid(
items: List<LibraryItem>,
subcategories: List<Pair<Category, List<LibraryItem>>>,
showTitle: Boolean,
columns: Int,
contentPadding: PaddingValues,
@ -30,6 +35,29 @@ internal fun LibraryCompactGrid(
) {
globalSearchItem(searchQuery, onGlobalSearchClicked)
items(
items = subcategories,
contentType = { "library_compact_grid_subcategory" },
) { (category, libraryItem) ->
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
val isSelected = if (selection.isEmpty()) {
false
} else {
selection.fastAny { it.category == libraryItem[0].libraryManga.category }
}
MangaCompactSubcategoryItem(
title = category.name.takeIf { showTitle },
covers = covers,
remainingCount = remainingCount,
isSelected = isSelected,
onClick = { onClick(libraryItem[0].libraryManga) },
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
)
}
items(
items = items,
contentType = { "library_compact_grid_item" },

View File

@ -35,15 +35,16 @@ fun LibraryContent(
hasActiveFilters: Boolean,
showPageTabs: Boolean,
onChangeCurrentPage: (Int) -> Unit,
onMangaClicked: (Long) -> Unit,
onMangaClicked: (LibraryManga) -> Unit,
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
onToggleSelection: (LibraryManga) -> Unit,
onToggleRangeSelection: (LibraryManga) -> Unit,
onRefresh: (Category?) -> Boolean,
onGlobalSearchClicked: () -> Unit,
getNumberOfMangaForCategory: (Category) -> Int?,
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
getLibraryForPage: (Int) -> List<LibraryItem>,
) {
Column(
@ -53,7 +54,7 @@ fun LibraryContent(
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
),
) {
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
val coercedCurrentPage = remember(categories) { currentPage().coerceAtMost(categories.lastIndex) }
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
val scope = rememberCoroutineScope()
@ -68,14 +69,14 @@ fun LibraryContent(
LibraryTabs(
categories = categories,
pagerState = pagerState,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
getSubcategoryAndMangaCountForCategory = getSubcategoryAndMangaCountForCategory,
) { scope.launch { pagerState.animateScrollToPage(it) } }
}
val notSelectionMode = selection.isEmpty()
val onClickManga = { manga: LibraryManga ->
if (notSelectionMode) {
onMangaClicked(manga.manga.id)
onMangaClicked(manga)
} else {
onToggleSelection(manga)
}
@ -105,6 +106,7 @@ fun LibraryContent(
getDisplayMode = getDisplayMode,
getColumnsForOrientation = getColumnsForOrientation,
getLibraryForPage = getLibraryForPage,
getSubcategoriesForPage = getSubcategoriesForPage,
onClickManga = onClickManga,
onLongClickManga = onToggleRangeSelection,
onClickContinueReading = onContinueReadingClicked,

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import eu.kanade.core.preference.PreferenceMutableState
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.presentation.core.components.HorizontalPager
@ -36,6 +37,7 @@ fun LibraryPager(
onGlobalSearchClicked: () -> Unit,
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
getLibraryForPage: (Int) -> List<LibraryItem>,
onClickManga: (LibraryManga) -> Unit,
onLongClickManga: (LibraryManga) -> Unit,
@ -51,8 +53,9 @@ fun LibraryPager(
return@HorizontalPager
}
val library = getLibraryForPage(page)
val subcategories = getSubcategoriesForPage(page)
if (library.isEmpty()) {
if (library.isEmpty() && subcategories.isEmpty()) {
LibraryPagerEmptyScreen(
searchQuery = searchQuery,
hasActiveFilters = hasActiveFilters,
@ -88,6 +91,7 @@ fun LibraryPager(
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
LibraryCompactGrid(
items = library,
subcategories = subcategories,
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
columns = columns,
contentPadding = contentPadding,
@ -102,6 +106,7 @@ fun LibraryPager(
LibraryDisplayMode.ComfortableGrid -> {
LibraryComfortableGrid(
items = library,
subcategories = subcategories,
columns = columns,
contentPadding = contentPadding,
selection = selectedManga,

View File

@ -17,7 +17,7 @@ import tachiyomi.presentation.core.components.material.TabText
internal fun LibraryTabs(
categories: List<Category>,
pagerState: PagerState,
getNumberOfMangaForCategory: (Category) -> Int?,
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
onTabItemClick: (Int) -> Unit,
) {
Column {
@ -34,9 +34,21 @@ internal fun LibraryTabs(
selected = pagerState.currentPage == index,
onClick = { onTabItemClick(index) },
text = {
val (subcategoryCount, mangaCount) = getSubcategoryAndMangaCountForCategory(category)
val badgeText = if (subcategoryCount != null && mangaCount != null) {
"($subcategoryCount, $mangaCount)"
} else if (mangaCount != null) {
"$mangaCount"
} else if (subcategoryCount != null) {
"$subcategoryCount"
} else {
""
}
TabText(
text = category.visualName,
badgeCount = getNumberOfMangaForCategory(category),
badgeText = badgeText,
)
},
unselectedContentColor = MaterialTheme.colorScheme.onSurface,

View File

@ -81,9 +81,19 @@ private fun LibraryRegularToolbar(
modifier = Modifier.weight(1f, false),
overflow = TextOverflow.Ellipsis,
)
if (title.numberOfManga != null) {
val (_, mangaCount, subcatCount) = title
if (mangaCount != null || subcatCount != null) {
val text = if (mangaCount != null && subcatCount != null) {
"($subcatCount, $mangaCount)"
} else if (mangaCount != null) {
"$mangaCount"
} else {
"$subcatCount"
}
Pill(
text = "${title.numberOfManga}",
text = text,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 14.sp,
)
@ -155,4 +165,5 @@ private fun LibrarySelectionToolbar(
data class LibraryToolbarTitle(
val text: String,
val numberOfManga: Int? = null,
val subcategoryCount: Int? = null,
)

View File

@ -29,6 +29,7 @@ import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.NewLabel
import androidx.compose.material.icons.outlined.RemoveDone
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
@ -224,6 +225,7 @@ fun LibraryBottomActionMenu(
onMarkAsUnreadClicked: () -> Unit,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onDeleteClicked: () -> Unit,
onCreateSubcategoryClick: (() -> Unit)?,
) {
AnimatedVisibility(
visible = visible,
@ -237,11 +239,11 @@ fun LibraryBottomActionMenu(
tonalElevation = 3.dp,
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<5).forEach { i -> confirm[i] = i == toConfirmIndex }
(0..<6).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
@ -301,6 +303,15 @@ fun LibraryBottomActionMenu(
onLongClick = { onLongClickItem(4) },
onClick = onDeleteClicked,
)
if (onCreateSubcategoryClick != null) {
Button(
title = stringResource(R.string.action_create_subcategory),
icon = Icons.Outlined.NewLabel,
toConfirm = confirm[5],
onLongClick = { onLongClickItem(5) },
onClick = onCreateSubcategoryClick,
)
}
}
}
}

View File

@ -37,7 +37,6 @@ import tachiyomi.data.Mangas
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences
@ -152,9 +151,7 @@ class BackupManager(
suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
getCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
handler.awaitList { categoriesQueries.getCategoriesWithGroupedParent(backupCategoryMapper) }
} else {
emptyList()
}
@ -249,13 +246,27 @@ class BackupManager(
// Get categories from file and from db
val dbCategories = getCategories.await()
val missingSubcategory = hashMapOf<Long, List<Long>>()
val categories = backupCategories.map {
val missingParent = it.parent.toMutableList()
var category = it.getCategory()
var found = false
for (dbCategory in dbCategories) {
val isInSameParent = if (it.parent.isEmpty()) true else it.parent.contains(dbCategory.parent)
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
// Same name can be used in categories with different parent
// so name alone isn't enough
if (category.name == dbCategory.name && isInSameParent) {
val parentIds = handler.awaitList {
subcategoriesQueries.getParentForChild(dbCategory.id)
}
missingParent.removeAll(parentIds)
category = category.copy(id = dbCategory.id)
found = true
break
@ -270,9 +281,17 @@ class BackupManager(
category = category.copy(id = id)
}
missingSubcategory[category.id] = missingParent
category
}
handler.await(inTransaction = true) {
missingSubcategory
.flatMap { it.value.map { parent -> it.key to parent } }
.forEach { subcategoriesQueries.insert(it.second, it.first) }
}
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
.distinctBy { it.flags }

View File

@ -8,6 +8,7 @@ import tachiyomi.domain.category.model.Category
class BackupCategory(
@ProtoNumber(1) var name: String,
@ProtoNumber(2) var order: Long = 0,
@ProtoNumber(3) var parent: List<Long> = emptyList(),
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Long = 0,
@ -18,14 +19,16 @@ class BackupCategory(
name = this@BackupCategory.name,
flags = this@BackupCategory.flags,
order = this@BackupCategory.order,
parent = -1,
)
}
}
val backupCategoryMapper = { category: Category ->
val backupCategoryMapper: (Long, String, Long, Long, String?) -> BackupCategory = { id, name, order, flags, parent ->
BackupCategory(
name = category.name,
order = category.order,
flags = category.flags,
name = name,
order = order,
flags = flags,
parent = parent?.split(",".toRegex())?.map { it.toLong() } ?: emptyList(),
)
}

View File

@ -44,6 +44,10 @@ import tachiyomi.core.preference.TriState
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.CreateSubcategory
import tachiyomi.domain.category.interactor.DeleteSubcategory
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
@ -78,6 +82,9 @@ class LibraryScreenModel(
private val getCategories: GetCategories = Injekt.get(),
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(),
private val createCategory: CreateCategoryWithName = Injekt.get(),
private val createSubcategory: CreateSubcategory = Injekt.get(),
private val deleteSubcategory: DeleteSubcategory = Injekt.get(),
private val getChaptersByMangaId: GetChapterByMangaId = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
@ -471,7 +478,7 @@ class LibraryScreenModel(
* @param deleteFromLibrary whether to delete manga from library.
* @param deleteChapters whether to delete downloaded chapters.
*/
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
fun removeItems(mangaList: List<Manga>, categories: List<Long>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
coroutineScope.launchNonCancellable {
val mangaToDelete = mangaList.distinctBy { it.id }
@ -494,6 +501,8 @@ class LibraryScreenModel(
}
}
}
if (deleteFromLibrary) deleteSubcategory.await(categories)
}
}
@ -627,11 +636,22 @@ class LibraryScreenModel(
fun openChangeCategoryDialog() {
coroutineScope.launchIO {
// Create a copy of selected manga
val mangaList = state.value.selection.map { it.manga }
val mainSelection = state.value.selection.filter { item ->
state.value.categories.any { it.id == item.category }
}
// Hide the default category because it has a different behavior than the ones from db.
val categories = state.value.categories.filter { it.id != 0L }
val subcategories = state.value.selection
.minus(mainSelection.toSet())
.map { item -> state.value.library.keys.first { it.id == item.category } }
// Create a copy of selected manga
val mangaList = mainSelection.map { it.manga }
val withSubcategory = mangaList.isNotEmpty()
val categories = state.value.library.keys
.filter { !it.isSystemCategory && if (!withSubcategory) state.value.categories.contains(it) else true }
.sortedBy { if (it.isSubcategory) 1 else 0 }
// Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList)
@ -644,23 +664,84 @@ class LibraryScreenModel(
else -> CheckboxState.State.None(it)
}
}
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, subcategories, preselected)) }
}
}
fun openDeleteMangaDialog() {
val mangaList = state.value.selection.map { it.manga }
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
// Items part of the main categories but not in the subcategories
val mainItems = state.value.selection.filter { manga ->
state.value.categories.fastAny { it.id == manga.category }
}
val subcategories = state.value.selection
.minus(mainItems.toSet())
.map { it.category }
val mangaList = mainItems.map { it.manga }
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList, subcategories)) }
}
fun closeDialog() {
mutableState.update { it.copy(dialog = null) }
}
fun openSubcategoryCreateDialog() {
val mangaIds = state.value.selection.map { it.manga }
mutableState.update { it.copy(dialog = Dialog.CreateSubcategory(mangaIds)) }
}
fun createSubcategory(name: String, parent: Long, mangas: List<Manga>) {
coroutineScope.launchIO {
val createResult = createCategory.await(name, isSubcategory = true)
if (createResult is CreateCategoryWithName.Result.Success) {
createSubcategory.await(parent, createResult.id)
setMangaCategories(mangas, listOf(createResult.id), emptyList())
} else {
logcat { "Error creating subcategory with error $createResult" }
}
clearSelection()
}
}
fun enterSubcategoryForItem(item: LibraryManga): Boolean {
return if (state.value.categories.fastAny { it.id == item.category }) {
false
} else {
mutableState.update {
val newState = it.copy(parentCategory = item.parentCategory)
activeCategoryIndex = newState.categories.indexOfFirst { it.id == item.category }
newState
}
true
}
}
fun leaveCurrentSubcategory() {
val parentCategory = state.value.parentCategory
if (parentCategory != null) {
val category = state.value.library.keys.first { it.id == parentCategory }
mutableState.update {
val newState = it.copy(parentCategory = if (category.isSubcategory) category.parent else null)
activeCategoryIndex = newState.categories.indexOf(category)
newState
}
}
}
sealed interface Dialog {
data object SettingsSheet : Dialog
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class DeleteManga(val manga: List<Manga>) : Dialog
data class ChangeCategory(val manga: List<Manga>, val category: List<Category>, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class DeleteManga(val manga: List<Manga>, val category: List<Long>) : Dialog
data class CreateSubcategory(val manga: List<Manga>) : Dialog
}
@Immutable
@ -681,6 +762,7 @@ class LibraryScreenModel(
data class State(
val isLoading: Boolean = true,
val library: LibraryMap = emptyMap(),
val parentCategory: Long? = null,
val searchQuery: String? = null,
val selection: List<LibraryManga> = emptyList(),
val hasActiveFilters: Boolean = false,
@ -690,40 +772,75 @@ class LibraryScreenModel(
val dialog: Dialog? = null,
) {
private val libraryCount by lazy {
library.values
val distinctItems = library.values
.flatten()
.fastDistinctBy { it.libraryManga.manga.id }
.size
if (parentCategory == null) {
distinctItems.size
} else {
val subCategories = getAllSubcategoriesForParent()
distinctItems.count { subCategories.contains(it.libraryManga.category) }
}
}
val isLibraryEmpty by lazy { libraryCount == 0 }
val selectionMode = selection.isNotEmpty()
val categories = library.keys.toList()
val categories = if (parentCategory == null) {
library.keys.filter { !it.isSubcategory }
} else {
library.keys.filter { it.parent == parentCategory }
}
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
}
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
return library.values.toTypedArray().getOrNull(page).orEmpty()
return library.getOrDefault(categories[page], emptyList())
// return if(parentCategory == null) library.values.toTypedArray().getOrNull(page).orEmpty()
// else {
// val category = mainCategories[page]
// library.getOrDefault(category, emptyList())
// }
}
fun getMangaCountForCategory(category: Category): Int? {
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null
}
fun getSubcategoryCountForCategory(id: Long): Int? {
return if (showMangaCount || !searchQuery.isNullOrEmpty()) {
val count = getSubcategoriesForCategory(id).size
if (count == 0) null else count
} else {
null
}
}
fun getToolbarTitle(
defaultTitle: String,
defaultCategoryTitle: String,
page: Int,
): LibraryToolbarTitle {
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
val categoryName = category.let {
if (it.isSystemCategory) defaultCategoryTitle else it.name
}
val title = if (showCategoryTabs) defaultTitle else categoryName
val title = if (showCategoryTabs && category.isSubcategory) {
library.keys.first { it.id == parentCategory }.name
} else if (showCategoryTabs) {
defaultTitle
} else {
categoryName
}
val count = when {
!showMangaCount -> null
!showCategoryTabs -> getMangaCountForCategory(category)
@ -731,7 +848,53 @@ class LibraryScreenModel(
else -> libraryCount
}
return LibraryToolbarTitle(title, count)
val subcategoryCount = when {
!showMangaCount -> null
!showCategoryTabs -> getSubcategoryCountForCategory(category.id)
category.isSubcategory -> getSubcategoryCountForCategory(category.parent)
else -> null
}
return LibraryToolbarTitle(title, count, subcategoryCount)
}
fun getSubcategoriesWithItemsForPage(index: Int): List<Pair<Category, List<LibraryItem>>> {
val id = categories[index].id
return library
.filter { (key, value) -> key.parent == id && value.isNotEmpty() }
.toList()
}
fun getSubcategoriesForCategory(id: Long): List<Category> {
return library.keys.filter { it.parent == id }
}
fun canCreateSubcategory(withSize: Boolean = true): Boolean {
if (withSize && selection.size < 2) return false
// All selections must be in the same category
val byCategory = selection.fastDistinctBy { it.category }
if (byCategory.size != 1) return false
return categories.fastAny { it.id == byCategory[0].category }
}
/**
* All subcategories inside this parent,
* both the ones directly under it and the subcategories under them
*/
private fun getAllSubcategoriesForParent(): List<Long> {
val allChild = mutableListOf<Long>().apply { addAll(categories.map { it.id }) }
var index = 0 // allChild.size
while (index < allChild.size) {
allChild.addAll(getSubcategoriesForCategory(allChild[index]).map { it.id })
index++
}
return allChild
}
}
}

View File

@ -29,6 +29,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.library.CreateSubcategoryDialog
import eu.kanade.presentation.library.DeleteLibraryMangaDialog
import eu.kanade.presentation.library.LibrarySettingsDialog
import eu.kanade.presentation.library.components.LibraryContent
@ -143,6 +144,7 @@ object LibraryTab : Tab {
onDownloadClicked = screenModel::runDownloadActionSelection
.takeIf { state.selection.fastAll { !it.manga.isLocal() } },
onDeleteClicked = screenModel::openDeleteMangaDialog,
onCreateSubcategoryClick = if (state.canCreateSubcategory()) screenModel::openSubcategoryCreateDialog else null,
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
@ -173,7 +175,7 @@ object LibraryTab : Tab {
hasActiveFilters = state.hasActiveFilters,
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
onMangaClicked = { navigator.push(MangaScreen(it)) },
onMangaClicked = { if (!screenModel.enterSubcategoryForItem(it)) navigator.push(MangaScreen(it.manga.id)) },
onContinueReadingClicked = { it: LibraryManga ->
scope.launchIO {
val chapter = screenModel.getNextUnreadChapter(it.manga)
@ -194,9 +196,10 @@ object LibraryTab : Tab {
onGlobalSearchClicked = {
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
},
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
getSubcategoryAndMangaCountForCategory = { state.getSubcategoryCountForCategory(it.id) to state.getMangaCountForCategory(it) },
getDisplayMode = { screenModel.getDisplayMode() },
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
getSubcategoriesForPage = { state.getSubcategoriesWithItemsForPage(it) },
) { state.getLibraryItemsByPage(it) }
}
}
@ -235,18 +238,28 @@ object LibraryTab : Tab {
containsLocalManga = dialog.manga.any(Manga::isLocal),
onDismissRequest = onDismissRequest,
onConfirm = { deleteManga, deleteChapter ->
screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter)
screenModel.removeItems(dialog.manga, dialog.category, deleteManga, deleteChapter)
screenModel.clearSelection()
},
)
}
is LibraryScreenModel.Dialog.CreateSubcategory -> {
val parent = state.categories.getOrNull(screenModel.activeCategoryIndex) ?: return
CreateSubcategoryDialog(
categories = state.getSubcategoriesForCategory(parent.id),
onDismissRequest = onDismissRequest,
onCreate = { screenModel.createSubcategory(it, parent.id, dialog.manga) },
)
}
null -> {}
}
BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
BackHandler(enabled = state.selectionMode || state.searchQuery != null || state.parentCategory != null) {
when {
state.selectionMode -> screenModel.clearSelection()
state.searchQuery != null -> screenModel.search(null)
state.parentCategory != null -> screenModel.leaveCurrentSubcategory()
}
}

View File

@ -2,11 +2,12 @@ package tachiyomi.data.category
import tachiyomi.domain.category.model.Category
val categoryMapper: (Long, String, Long, Long) -> Category = { id, name, order, flags ->
val categoryMapper: (Long, String, Long, Long, Long) -> Category = { id, name, order, flags, parent ->
Category(
id = id,
name = name,
order = order,
flags = flags,
parent = parent,
)
}

View File

@ -81,4 +81,28 @@ class CategoryRepositoryImpl(
)
}
}
override suspend fun getMaxSubcategoryOrder(): Long {
return handler.awaitOne {
categoriesQueries.getMaxSubcategoryOrder()
}
}
override suspend fun lastInsertedId(): Long {
return handler.awaitOneExecutable {
categoriesQueries.selectLastInsertedRowId()
}
}
override suspend fun insertSubcategory(parent: Long, child: Long) {
handler.await {
subcategoriesQueries.insert(parent, child)
}
}
override suspend fun deleteSubcategories(categories: List<Long>) {
handler.await(inTransaction = true) {
subcategoriesQueries.bulkDelete(categories)
}
}
}

View File

@ -32,8 +32,8 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?,
)
}
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long?, Long, Double, Long, Long, Long, Double, Long) -> LibraryManga =
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category ->
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long?, Long, Double, Long, Long, Long, Double, Long, Long) -> LibraryManga =
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category, parentCategory ->
LibraryManga(
manga = mangaMapper(
id,
@ -60,6 +60,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
favoriteModifiedAt,
),
category = category,
parentCategory = parentCategory,
totalChapters = totalCount,
readCount = readCount.toLong(),
bookmarkCount = bookmarkCount.toLong(),

View File

@ -17,18 +17,21 @@ BEGIN SELECT CASE
END;
getCategory:
SELECT *
SELECT *, -1 AS parent
FROM categories
WHERE _id = :id
LIMIT 1;
getCategories:
SELECT
_id AS id,
name,
sort AS `order`,
flags
categories._id AS id,
categories.name,
categories.sort AS `order`,
categories.flags,
coalesce(subcategory.parent, -1) AS parent
FROM categories
LEFT JOIN subcategory
ON categories._id = subcategory.child
ORDER BY sort;
getCategoriesByMangaId:
@ -36,12 +39,35 @@ SELECT
C._id AS id,
C.name,
C.sort AS `order`,
C.flags
C.flags,
coalesce(SC.parent, -1) AS parent
FROM categories C
LEFT JOIN subcategory SC
ON C._id = SC.child
JOIN mangas_categories MC
ON C._id = MC.category_id
WHERE MC.manga_id = :mangaId;
getMaxSubcategoryOrder:
SELECT
coalesce(max(sort), 0) AS max
FROM categories
JOIN subcategory
ON categories._id = subcategory.child;
getCategoriesWithGroupedParent:
SELECT
categories._id AS id,
categories.name,
categories.sort AS `order`,
categories.flags,
group_concat(subcategory.parent) AS parent
FROM categories
LEFT JOIN subcategory
ON categories._id = subcategory.child
WHERE categories._id IS NOT 0
GROUP BY id;
insert:
INSERT INTO categories(name, sort, flags)
VALUES (:name, :order, :flags);

View File

@ -0,0 +1,29 @@
CREATE TABLE subcategory(
parent INTEGER NOT NULL,
child INTEGER NOT NULL,
PRIMARY KEY (parent, child),
FOREIGN KEY(parent) REFERENCES categories(_id) ON DELETE CASCADE,
FOREIGN KEY(child) REFERENCES categories(_id) ON DELETE CASCADE
);
CREATE TRIGGER remove_subcategory_when_no_parent
AFTER DELETE ON subcategory
WHEN
old.child NOT IN (SELECT child FROM subcategory)
BEGIN
DELETE FROM categories WHERE _id = old.child;
END;
insert:
INSERT INTO subcategory(parent, child)
VALUES (?, ?);
bulkDelete:
DELETE FROM subcategory
WHERE child IN ?;
getParentForChild:
SELECT
parent
FROM subcategory
WHERE child = ?;

View File

@ -0,0 +1,67 @@
CREATE TABLE subcategory(
parent INTEGER NOT NULL,
child INTEGER NOT NULL,
PRIMARY KEY (parent, child),
FOREIGN KEY(parent) REFERENCES categories(_id) ON DELETE CASCADE,
FOREIGN KEY(child) REFERENCES categories(_id) ON DELETE CASCADE
);
CREATE TRIGGER remove_subcategory_when_no_parent
AFTER DELETE ON subcategory
WHEN
old.child NOT IN (SELECT child FROM subcategory)
BEGIN
DELETE FROM categories WHERE _id = old.child;
END;
DROP VIEW libraryView;
CREATE VIEW libraryView AS
SELECT
M.*,
coalesce(C.total, 0) AS totalCount,
coalesce(C.readCount, 0) AS readCount,
coalesce(C.latestUpload, 0) AS latestUpload,
coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.category_id, 0) AS category,
coalesce(MC.parent, -1) AS parentCategory
FROM mangas M
LEFT JOIN(
SELECT
chapters.manga_id,
count(*) AS total,
sum(read) AS readCount,
coalesce(max(chapters.date_upload), 0) AS latestUpload,
coalesce(max(history.last_read), 0) AS lastRead,
coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
sum(chapters.bookmark) AS bookmarkCount
FROM chapters
LEFT JOIN history
ON chapters._id = history.chapter_id
GROUP BY chapters.manga_id
) AS C
ON M._id = C.manga_id
LEFT JOIN (
SELECT
mc.*,
s.parent
FROM
mangas_categories AS mc
LEFT JOIN subcategory AS s
ON mc.category_id = s.child
WHERE mc._id NOT IN (
-- Get ids of mangas_categories that belong to parent category where the same manga_id already exists in a subcategory
SELECT DISTINCT
mc_temp._id
FROM mangas_categories mc_temp
JOIN subcategory s_temp
ON mc_temp.category_id = s_temp.parent
JOIN mangas_categories AS mc_temp_sub
ON mc_temp.manga_id = mc_temp_sub.manga_id
AND s_temp.child = mc_temp_sub.category_id
)
) AS MC
ON MC.manga_id = M._id
WHERE M.favorite = 1;

View File

@ -7,7 +7,8 @@ SELECT
coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.category_id, 0) AS category
coalesce(MC.category_id, 0) AS category,
coalesce(MC.parent, -1) AS parentCategory
FROM mangas M
LEFT JOIN(
SELECT
@ -24,7 +25,26 @@ LEFT JOIN(
GROUP BY chapters.manga_id
) AS C
ON M._id = C.manga_id
LEFT JOIN mangas_categories AS MC
LEFT JOIN (
SELECT
mc.*,
s.parent
FROM
mangas_categories AS mc
LEFT JOIN subcategory AS s
ON mc.category_id = s.child
WHERE mc._id NOT IN (
-- Get ids of mangas_categories that belong to parent category where the same manga_id already exists in a subcategory
SELECT DISTINCT
mc_temp._id
FROM mangas_categories mc_temp
JOIN subcategory s_temp
ON mc_temp.category_id = s_temp.parent
JOIN mangas_categories AS mc_temp_sub
ON mc_temp.manga_id = mc_temp_sub.manga_id
AND s_temp.child = mc_temp_sub.category_id
)
) AS MC
ON MC.manga_id = M._id
WHERE M.favorite = 1;

View File

@ -18,19 +18,32 @@ class CreateCategoryWithName(
return sort.type.flag or sort.direction.flag
}
suspend fun await(name: String): Result = withNonCancellableContext {
val categories = categoryRepository.getAll()
val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0
suspend fun await(name: String, isSubcategory: Boolean = false): Result = withNonCancellableContext {
// subcategories should not interfere with the sort order of parent categories
val nextOrder = if (isSubcategory) {
val maxOrder = categoryRepository.getMaxSubcategoryOrder()
if (maxOrder == 0L) {
Int.MIN_VALUE.toLong()
} else {
maxOrder + 1
}
} else {
val categories = categoryRepository.getAll()
categories.maxOfOrNull { it.order }?.plus(1) ?: 0
}
val newCategory = Category(
id = 0,
name = name,
order = nextOrder,
flags = initialFlags,
parent = -1,
)
try {
categoryRepository.insert(newCategory)
Result.Success
Result.Success(categoryRepository.lastInsertedId())
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
@ -38,7 +51,7 @@ class CreateCategoryWithName(
}
sealed interface Result {
data object Success : Result
data class Success(val id: Long) : Result
data class InternalError(val error: Throwable) : Result
}
}

View File

@ -0,0 +1,12 @@
package tachiyomi.domain.category.interactor
import tachiyomi.domain.category.repository.CategoryRepository
class CreateSubcategory(
private val repo: CategoryRepository,
) {
suspend fun await(parent: Long, child: Long) {
repo.insertSubcategory(parent, child)
}
}

View File

@ -0,0 +1,12 @@
package tachiyomi.domain.category.interactor
import tachiyomi.domain.category.repository.CategoryRepository
class DeleteSubcategory(
private val repo: CategoryRepository,
) {
suspend fun await(categories: List<Long>) {
repo.deleteSubcategories(categories)
}
}

View File

@ -7,10 +7,13 @@ data class Category(
val name: String,
val order: Long,
val flags: Long,
val parent: Long,
) : Serializable {
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
val isSubcategory = parent > -1
companion object {
const val UNCATEGORIZED_ID = 0L
}

View File

@ -25,4 +25,12 @@ interface CategoryRepository {
suspend fun updateAllFlags(flags: Long?)
suspend fun delete(categoryId: Long)
suspend fun getMaxSubcategoryOrder(): Long
suspend fun lastInsertedId(): Long
suspend fun insertSubcategory(parent: Long, child: Long)
suspend fun deleteSubcategories(categories: List<Long>)
}

View File

@ -5,6 +5,7 @@ import tachiyomi.domain.manga.model.Manga
data class LibraryManga(
val manga: Manga,
val category: Long,
val parentCategory: Long,
val totalChapters: Long,
val readCount: Long,
val bookmarkCount: Long,

View File

@ -105,6 +105,9 @@
<string name="action_open_in_browser">Open in browser</string>
<string name="action_show_manga">Show entry</string>
<string name="action_copy_to_clipboard">Copy to clipboard</string>
<string name="action_create_subcategory">Create subcategory</string>
<string name="action_add_subcategory">Add subcategory</string>
<!-- Do not translate "WebView" -->
<string name="action_open_in_web_view">Open in WebView</string>
<string name="action_web_view" translatable="false">WebView</string>
@ -748,6 +751,7 @@
<!-- Category activity -->
<string name="error_category_exists">A category with this name already exists!</string>
<string name="error_subcategory_exists">A subcategory with this name already exists!</string>
<!-- missing undo feature after Compose rewrite #7454 -->
<string name="snack_categories_deleted">Categories deleted</string>

View File

@ -61,7 +61,7 @@ fun TabIndicator(
@Composable
fun TabText(
text: String,
badgeCount: Int? = null,
badgeText: String? = null,
) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
@ -69,9 +69,9 @@ fun TabText(
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = text)
if (badgeCount != null) {
if (!badgeText.isNullOrEmpty()) {
Pill(
text = "$badgeCount",
text = badgeText,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 10.sp,
)