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

View file

@ -75,7 +75,7 @@ fun TabbedScreen(
Tab( Tab(
selected = state.currentPage == index, selected = state.currentPage == index,
onClick = { scope.launch { state.animateScrollToPage(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, 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.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow 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.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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] * 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. * 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 @Composable
private fun GridItemTitle( private fun GridItemTitle(
modifier: Modifier, 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 @ThemePreviews
@Composable @Composable
private fun BadgePreview() { private fun BadgePreview() {

View file

@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.manga.model.asMangaCover
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
@Composable @Composable
internal fun LibraryComfortableGrid( internal fun LibraryComfortableGrid(
items: List<LibraryItem>, items: List<LibraryItem>,
subcategories: List<Pair<Category, List<LibraryItem>>>,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: List<LibraryManga>,
@ -29,6 +34,23 @@ internal fun LibraryComfortableGrid(
) { ) {
globalSearchItem(searchQuery, onGlobalSearchClicked) 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 = items, items = items,
contentType = { "library_comfortable_grid_item" }, 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.Modifier
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.manga.model.asMangaCover
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
@Composable @Composable
internal fun LibraryCompactGrid( internal fun LibraryCompactGrid(
items: List<LibraryItem>, items: List<LibraryItem>,
subcategories: List<Pair<Category, List<LibraryItem>>>,
showTitle: Boolean, showTitle: Boolean,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
@ -30,6 +35,29 @@ internal fun LibraryCompactGrid(
) { ) {
globalSearchItem(searchQuery, onGlobalSearchClicked) 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 = items, items = items,
contentType = { "library_compact_grid_item" }, contentType = { "library_compact_grid_item" },

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import tachiyomi.presentation.core.components.material.TabText
internal fun LibraryTabs( internal fun LibraryTabs(
categories: List<Category>, categories: List<Category>,
pagerState: PagerState, pagerState: PagerState,
getNumberOfMangaForCategory: (Category) -> Int?, getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
onTabItemClick: (Int) -> Unit, onTabItemClick: (Int) -> Unit,
) { ) {
Column { Column {
@ -34,9 +34,21 @@ internal fun LibraryTabs(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { onTabItemClick(index) }, onClick = { onTabItemClick(index) },
text = { 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( TabText(
text = category.visualName, text = category.visualName,
badgeCount = getNumberOfMangaForCategory(category), badgeText = badgeText,
) )
}, },
unselectedContentColor = MaterialTheme.colorScheme.onSurface, unselectedContentColor = MaterialTheme.colorScheme.onSurface,

View file

@ -81,9 +81,19 @@ private fun LibraryRegularToolbar(
modifier = Modifier.weight(1f, false), modifier = Modifier.weight(1f, false),
overflow = TextOverflow.Ellipsis, 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( Pill(
text = "${title.numberOfManga}", text = text,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 14.sp, fontSize = 14.sp,
) )
@ -155,4 +165,5 @@ private fun LibrarySelectionToolbar(
data class LibraryToolbarTitle( data class LibraryToolbarTitle(
val text: String, val text: String,
val numberOfManga: Int? = null, 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.DoneAll
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Label 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.icons.outlined.RemoveDone
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -224,6 +225,7 @@ fun LibraryBottomActionMenu(
onMarkAsUnreadClicked: () -> Unit, onMarkAsUnreadClicked: () -> Unit,
onDownloadClicked: ((DownloadAction) -> Unit)?, onDownloadClicked: ((DownloadAction) -> Unit)?,
onDeleteClicked: () -> Unit, onDeleteClicked: () -> Unit,
onCreateSubcategoryClick: (() -> Unit)?,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
@ -237,11 +239,11 @@ fun LibraryBottomActionMenu(
tonalElevation = 3.dp, tonalElevation = 3.dp,
) { ) {
val haptic = LocalHapticFeedback.current 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 } var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<5).forEach { i -> confirm[i] = i == toConfirmIndex } (0..<6).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel() resetJob?.cancel()
resetJob = scope.launch { resetJob = scope.launch {
delay(1.seconds) delay(1.seconds)
@ -301,6 +303,15 @@ fun LibraryBottomActionMenu(
onLongClick = { onLongClickItem(4) }, onLongClick = { onLongClickItem(4) },
onClick = onDeleteClicked, 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.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
@ -152,9 +151,7 @@ class BackupManager(
suspend fun backupCategories(options: Int): List<BackupCategory> { suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup // Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
getCategories.await() handler.awaitList { categoriesQueries.getCategoriesWithGroupedParent(backupCategoryMapper) }
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else { } else {
emptyList() emptyList()
} }
@ -249,13 +246,27 @@ class BackupManager(
// Get categories from file and from db // Get categories from file and from db
val dbCategories = getCategories.await() val dbCategories = getCategories.await()
val missingSubcategory = hashMapOf<Long, List<Long>>()
val categories = backupCategories.map { val categories = backupCategories.map {
val missingParent = it.parent.toMutableList()
var category = it.getCategory() var category = it.getCategory()
var found = false var found = false
for (dbCategory in dbCategories) { 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 // If the category is already in the db, assign the id to the file's category
// and do nothing // 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) category = category.copy(id = dbCategory.id)
found = true found = true
break break
@ -270,9 +281,17 @@ class BackupManager(
category = category.copy(id = id) category = category.copy(id = id)
} }
missingSubcategory[category.id] = missingParent
category 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( libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories) (dbCategories + categories)
.distinctBy { it.flags } .distinctBy { it.flags }

View file

@ -8,6 +8,7 @@ import tachiyomi.domain.category.model.Category
class BackupCategory( class BackupCategory(
@ProtoNumber(1) var name: String, @ProtoNumber(1) var name: String,
@ProtoNumber(2) var order: Long = 0, @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 // @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 // Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Long = 0, @ProtoNumber(100) var flags: Long = 0,
@ -18,14 +19,16 @@ class BackupCategory(
name = this@BackupCategory.name, name = this@BackupCategory.name,
flags = this@BackupCategory.flags, flags = this@BackupCategory.flags,
order = this@BackupCategory.order, order = this@BackupCategory.order,
parent = -1,
) )
} }
} }
val backupCategoryMapper = { category: Category -> val backupCategoryMapper: (Long, String, Long, Long, String?) -> BackupCategory = { id, name, order, flags, parent ->
BackupCategory( BackupCategory(
name = category.name, name = name,
order = category.order, order = order,
flags = category.flags, 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.launchIO
import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext 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.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@ -78,6 +82,9 @@ class LibraryScreenModel(
private val getCategories: GetCategories = Injekt.get(), private val getCategories: GetCategories = Injekt.get(),
private val getTracksPerManga: GetTracksPerManga = Injekt.get(), private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
private val getNextChapters: GetNextChapters = 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 getChaptersByMangaId: GetChapterByMangaId = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(), private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(),
@ -471,7 +478,7 @@ class LibraryScreenModel(
* @param deleteFromLibrary whether to delete manga from library. * @param deleteFromLibrary whether to delete manga from library.
* @param deleteChapters whether to delete downloaded chapters. * @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 { coroutineScope.launchNonCancellable {
val mangaToDelete = mangaList.distinctBy { it.id } 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() { fun openChangeCategoryDialog() {
coroutineScope.launchIO { coroutineScope.launchIO {
// Create a copy of selected manga val mainSelection = state.value.selection.filter { item ->
val mangaList = state.value.selection.map { it.manga } state.value.categories.any { it.id == item.category }
}
// Hide the default category because it has a different behavior than the ones from db. val subcategories = state.value.selection
val categories = state.value.categories.filter { it.id != 0L } .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. // Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList) val common = getCommonCategories(mangaList)
@ -644,23 +664,84 @@ class LibraryScreenModel(
else -> CheckboxState.State.None(it) 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() { fun openDeleteMangaDialog() {
val mangaList = state.value.selection.map { it.manga } // Items part of the main categories but not in the subcategories
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) } 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() { fun closeDialog() {
mutableState.update { it.copy(dialog = null) } 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 { sealed interface Dialog {
data object SettingsSheet : Dialog data object SettingsSheet : Dialog
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : 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>) : Dialog data class DeleteManga(val manga: List<Manga>, val category: List<Long>) : Dialog
data class CreateSubcategory(val manga: List<Manga>) : Dialog
} }
@Immutable @Immutable
@ -681,6 +762,7 @@ class LibraryScreenModel(
data class State( data class State(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val library: LibraryMap = emptyMap(), val library: LibraryMap = emptyMap(),
val parentCategory: Long? = null,
val searchQuery: String? = null, val searchQuery: String? = null,
val selection: List<LibraryManga> = emptyList(), val selection: List<LibraryManga> = emptyList(),
val hasActiveFilters: Boolean = false, val hasActiveFilters: Boolean = false,
@ -690,40 +772,75 @@ class LibraryScreenModel(
val dialog: Dialog? = null, val dialog: Dialog? = null,
) { ) {
private val libraryCount by lazy { private val libraryCount by lazy {
library.values val distinctItems = library.values
.flatten() .flatten()
.fastDistinctBy { it.libraryManga.manga.id } .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 isLibraryEmpty by lazy { libraryCount == 0 }
val selectionMode = selection.isNotEmpty() 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>? { fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } } return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
} }
fun getLibraryItemsByPage(page: Int): List<LibraryItem> { 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? { fun getMangaCountForCategory(category: Category): Int? {
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null 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( fun getToolbarTitle(
defaultTitle: String, defaultTitle: String,
defaultCategoryTitle: String, defaultCategoryTitle: String,
page: Int, page: Int,
): LibraryToolbarTitle { ): LibraryToolbarTitle {
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle) val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
val categoryName = category.let { val categoryName = category.let {
if (it.isSystemCategory) defaultCategoryTitle else it.name 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 { val count = when {
!showMangaCount -> null !showMangaCount -> null
!showCategoryTabs -> getMangaCountForCategory(category) !showCategoryTabs -> getMangaCountForCategory(category)
@ -731,7 +848,53 @@ class LibraryScreenModel(
else -> libraryCount 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.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.library.CreateSubcategoryDialog
import eu.kanade.presentation.library.DeleteLibraryMangaDialog import eu.kanade.presentation.library.DeleteLibraryMangaDialog
import eu.kanade.presentation.library.LibrarySettingsDialog import eu.kanade.presentation.library.LibrarySettingsDialog
import eu.kanade.presentation.library.components.LibraryContent import eu.kanade.presentation.library.components.LibraryContent
@ -143,6 +144,7 @@ object LibraryTab : Tab {
onDownloadClicked = screenModel::runDownloadActionSelection onDownloadClicked = screenModel::runDownloadActionSelection
.takeIf { state.selection.fastAll { !it.manga.isLocal() } }, .takeIf { state.selection.fastAll { !it.manga.isLocal() } },
onDeleteClicked = screenModel::openDeleteMangaDialog, onDeleteClicked = screenModel::openDeleteMangaDialog,
onCreateSubcategoryClick = if (state.canCreateSubcategory()) screenModel::openSubcategoryCreateDialog else null,
) )
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
@ -173,7 +175,7 @@ object LibraryTab : Tab {
hasActiveFilters = state.hasActiveFilters, hasActiveFilters = state.hasActiveFilters,
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(), showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
onChangeCurrentPage = { screenModel.activeCategoryIndex = it }, onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
onMangaClicked = { navigator.push(MangaScreen(it)) }, onMangaClicked = { if (!screenModel.enterSubcategoryForItem(it)) navigator.push(MangaScreen(it.manga.id)) },
onContinueReadingClicked = { it: LibraryManga -> onContinueReadingClicked = { it: LibraryManga ->
scope.launchIO { scope.launchIO {
val chapter = screenModel.getNextUnreadChapter(it.manga) val chapter = screenModel.getNextUnreadChapter(it.manga)
@ -194,9 +196,10 @@ object LibraryTab : Tab {
onGlobalSearchClicked = { onGlobalSearchClicked = {
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: "")) navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
}, },
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, getSubcategoryAndMangaCountForCategory = { state.getSubcategoryCountForCategory(it.id) to state.getMangaCountForCategory(it) },
getDisplayMode = { screenModel.getDisplayMode() }, getDisplayMode = { screenModel.getDisplayMode() },
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) }, getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
getSubcategoriesForPage = { state.getSubcategoriesWithItemsForPage(it) },
) { state.getLibraryItemsByPage(it) } ) { state.getLibraryItemsByPage(it) }
} }
} }
@ -235,18 +238,28 @@ object LibraryTab : Tab {
containsLocalManga = dialog.manga.any(Manga::isLocal), containsLocalManga = dialog.manga.any(Manga::isLocal),
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onConfirm = { deleteManga, deleteChapter -> onConfirm = { deleteManga, deleteChapter ->
screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter) screenModel.removeItems(dialog.manga, dialog.category, deleteManga, deleteChapter)
screenModel.clearSelection() 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 -> {} null -> {}
} }
BackHandler(enabled = state.selectionMode || state.searchQuery != null) { BackHandler(enabled = state.selectionMode || state.searchQuery != null || state.parentCategory != null) {
when { when {
state.selectionMode -> screenModel.clearSelection() state.selectionMode -> screenModel.clearSelection()
state.searchQuery != null -> screenModel.search(null) 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 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( Category(
id = id, id = id,
name = name, name = name,
order = order, order = order,
flags = flags, 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 = 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 -> { 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( LibraryManga(
manga = mangaMapper( manga = mangaMapper(
id, id,
@ -60,6 +60,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
favoriteModifiedAt, favoriteModifiedAt,
), ),
category = category, category = category,
parentCategory = parentCategory,
totalChapters = totalCount, totalChapters = totalCount,
readCount = readCount.toLong(), readCount = readCount.toLong(),
bookmarkCount = bookmarkCount.toLong(), bookmarkCount = bookmarkCount.toLong(),

View file

@ -17,18 +17,21 @@ BEGIN SELECT CASE
END; END;
getCategory: getCategory:
SELECT * SELECT *, -1 AS parent
FROM categories FROM categories
WHERE _id = :id WHERE _id = :id
LIMIT 1; LIMIT 1;
getCategories: getCategories:
SELECT SELECT
_id AS id, categories._id AS id,
name, categories.name,
sort AS `order`, categories.sort AS `order`,
flags categories.flags,
coalesce(subcategory.parent, -1) AS parent
FROM categories FROM categories
LEFT JOIN subcategory
ON categories._id = subcategory.child
ORDER BY sort; ORDER BY sort;
getCategoriesByMangaId: getCategoriesByMangaId:
@ -36,12 +39,35 @@ SELECT
C._id AS id, C._id AS id,
C.name, C.name,
C.sort AS `order`, C.sort AS `order`,
C.flags C.flags,
coalesce(SC.parent, -1) AS parent
FROM categories C FROM categories C
LEFT JOIN subcategory SC
ON C._id = SC.child
JOIN mangas_categories MC JOIN mangas_categories MC
ON C._id = MC.category_id ON C._id = MC.category_id
WHERE MC.manga_id = :mangaId; 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:
INSERT INTO categories(name, sort, flags) INSERT INTO categories(name, sort, flags)
VALUES (:name, :order, :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.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead, coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount, 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 FROM mangas M
LEFT JOIN( LEFT JOIN(
SELECT SELECT
@ -24,7 +25,26 @@ LEFT JOIN(
GROUP BY chapters.manga_id GROUP BY chapters.manga_id
) AS C ) AS C
ON M._id = C.manga_id 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 ON MC.manga_id = M._id
WHERE M.favorite = 1; WHERE M.favorite = 1;

View file

@ -18,19 +18,32 @@ class CreateCategoryWithName(
return sort.type.flag or sort.direction.flag return sort.type.flag or sort.direction.flag
} }
suspend fun await(name: String): Result = withNonCancellableContext { 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() val categories = categoryRepository.getAll()
val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0 categories.maxOfOrNull { it.order }?.plus(1) ?: 0
}
val newCategory = Category( val newCategory = Category(
id = 0, id = 0,
name = name, name = name,
order = nextOrder, order = nextOrder,
flags = initialFlags, flags = initialFlags,
parent = -1,
) )
try { try {
categoryRepository.insert(newCategory) categoryRepository.insert(newCategory)
Result.Success Result.Success(categoryRepository.lastInsertedId())
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
Result.InternalError(e) Result.InternalError(e)
@ -38,7 +51,7 @@ class CreateCategoryWithName(
} }
sealed interface Result { sealed interface Result {
data object Success : Result data class Success(val id: Long) : Result
data class InternalError(val error: Throwable) : 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 name: String,
val order: Long, val order: Long,
val flags: Long, val flags: Long,
val parent: Long,
) : Serializable { ) : Serializable {
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
val isSubcategory = parent > -1
companion object { companion object {
const val UNCATEGORIZED_ID = 0L const val UNCATEGORIZED_ID = 0L
} }

View file

@ -25,4 +25,12 @@ interface CategoryRepository {
suspend fun updateAllFlags(flags: Long?) suspend fun updateAllFlags(flags: Long?)
suspend fun delete(categoryId: 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( data class LibraryManga(
val manga: Manga, val manga: Manga,
val category: Long, val category: Long,
val parentCategory: Long,
val totalChapters: Long, val totalChapters: Long,
val readCount: Long, val readCount: Long,
val bookmarkCount: Long, val bookmarkCount: Long,

View file

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

View file

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