diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index aac1f060d6..1091d7f96a 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -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 { MangaRepositoryImpl(get()) } addFactory { GetDuplicateLibraryManga(get()) } diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index 422b1f7dc6..69288b2cb1 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt @@ -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, ) } diff --git a/app/src/main/java/eu/kanade/presentation/library/CreateSubcategoryDialog.kt b/app/src/main/java/eu/kanade/presentation/library/CreateSubcategoryDialog.kt new file mode 100644 index 0000000000..7aa78e7cae --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/CreateSubcategoryDialog.kt @@ -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, + 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() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/CommonMangaItem.kt b/app/src/main/java/eu/kanade/presentation/library/components/CommonMangaItem.kt index 5f83c764f4..52a03d7f3e 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/CommonMangaItem.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/CommonMangaItem.kt @@ -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, + 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, + 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, + 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, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt index 0ab1a3c286..1b1341bf21 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt @@ -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() { diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt index 379d4054c2..0ebabbf50f 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt @@ -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, + subcategories: List>>, columns: Int, contentPadding: PaddingValues, selection: List, @@ -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" }, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt index 56caa99e06..35ef5d7c14 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt @@ -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, + subcategories: List>>, 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" }, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt index 61da10345e..18c15b8cd2 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt @@ -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, getDisplayMode: (Int) -> PreferenceMutableState, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getSubcategoriesForPage: (Int) -> List>>, getLibraryForPage: (Int) -> List, ) { 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, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt index ace52a11c5..82e9d2ec48 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -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, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getSubcategoriesForPage: (Int) -> List>>, getLibraryForPage: (Int) -> List, 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, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt index 6f68c78def..5fd6d91032 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt @@ -17,7 +17,7 @@ import tachiyomi.presentation.core.components.material.TabText internal fun LibraryTabs( categories: List, pagerState: PagerState, - getNumberOfMangaForCategory: (Category) -> Int?, + getSubcategoryAndMangaCountForCategory: (Category) -> Pair, 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, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt index f19397e2f1..cc5603ee31 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -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, ) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index eac2228f12..0d6e838602 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt index 35a9ea5987..6e39458ea4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -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 { // 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>() + 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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupCategory.kt index 8d93a0b32d..b1c4925fbc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupCategory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupCategory.kt @@ -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 = 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(), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 2441707fe3..74b8ab29f2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -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, deleteFromLibrary: Boolean, deleteChapters: Boolean) { + fun removeItems(mangaList: List, categories: List, 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) { + 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, val initialSelection: List>) : Dialog - data class DeleteManga(val manga: List) : Dialog + data class ChangeCategory(val manga: List, val category: List, val initialSelection: List>) : Dialog + data class DeleteManga(val manga: List, val category: List) : Dialog + data class CreateSubcategory(val manga: List) : 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 = 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? { return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } } } fun getLibraryItemsByPage(page: Int): List { - 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>> { + val id = categories[index].id + + return library + .filter { (key, value) -> key.parent == id && value.isNotEmpty() } + .toList() + } + + fun getSubcategoriesForCategory(id: Long): List { + 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 { + val allChild = mutableListOf().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 } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index c3a11d0349..f69b499176 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -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() } } diff --git a/data/src/main/java/tachiyomi/data/category/CategoryMapper.kt b/data/src/main/java/tachiyomi/data/category/CategoryMapper.kt index 37f18ae808..22647b167e 100644 --- a/data/src/main/java/tachiyomi/data/category/CategoryMapper.kt +++ b/data/src/main/java/tachiyomi/data/category/CategoryMapper.kt @@ -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, ) } diff --git a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt index 75cfbf7d17..73e5e73f4b 100644 --- a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt @@ -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) { + handler.await(inTransaction = true) { + subcategoriesQueries.bulkDelete(categories) + } + } } diff --git a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt index 3cc46aa5cb..84802f5e5d 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt @@ -32,8 +32,8 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List?, ) } -val libraryManga: (Long, Long, String, String?, String?, String?, List?, 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, 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?, favoriteModifiedAt, ), category = category, + parentCategory = parentCategory, totalChapters = totalCount, readCount = readCount.toLong(), bookmarkCount = bookmarkCount.toLong(), diff --git a/data/src/main/sqldelight/tachiyomi/data/categories.sq b/data/src/main/sqldelight/tachiyomi/data/categories.sq index a25e6ca3ba..6f5549305c 100644 --- a/data/src/main/sqldelight/tachiyomi/data/categories.sq +++ b/data/src/main/sqldelight/tachiyomi/data/categories.sq @@ -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); diff --git a/data/src/main/sqldelight/tachiyomi/data/subcategories.sq b/data/src/main/sqldelight/tachiyomi/data/subcategories.sq new file mode 100644 index 0000000000..a4c9723404 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/subcategories.sq @@ -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 = ?; \ No newline at end of file diff --git a/data/src/main/sqldelight/tachiyomi/migrations/26.sqm b/data/src/main/sqldelight/tachiyomi/migrations/26.sqm new file mode 100644 index 0000000000..cdec8145fd --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/26.sqm @@ -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; \ No newline at end of file diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index 4b1468872e..51bf03638b 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -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; diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt index f8b4ba8023..7ee11c493f 100644 --- a/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt @@ -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 } } diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/CreateSubcategory.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateSubcategory.kt new file mode 100644 index 0000000000..6872e6b0cf --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateSubcategory.kt @@ -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) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteSubcategory.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteSubcategory.kt new file mode 100644 index 0000000000..b6eb5fbbd3 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteSubcategory.kt @@ -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) { + repo.deleteSubcategories(categories) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/category/model/Category.kt b/domain/src/main/java/tachiyomi/domain/category/model/Category.kt index ea901ce80f..63861013a0 100644 --- a/domain/src/main/java/tachiyomi/domain/category/model/Category.kt +++ b/domain/src/main/java/tachiyomi/domain/category/model/Category.kt @@ -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 } diff --git a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt index e2f8871df7..925ec017c0 100644 --- a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt @@ -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) } diff --git a/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt b/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt index 65e06c1952..c786dd4e6c 100644 --- a/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt +++ b/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt @@ -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, diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 8560883add..c06ee1cc67 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -105,6 +105,9 @@ Open in browser Show entry Copy to clipboard + Create subcategory + Add subcategory + Open in WebView WebView @@ -748,6 +751,7 @@ A category with this name already exists! + A subcategory with this name already exists! Categories deleted diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/Tabs.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/Tabs.kt index d547ad8d02..0e42f8d108 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/Tabs.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/Tabs.kt @@ -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, )