implement subcategory
This commit is contained in:
parent
d1c956401c
commit
48648d1804
31 changed files with 779 additions and 61 deletions
|
@ -27,7 +27,9 @@ import tachiyomi.data.source.StubSourceRepositoryImpl
|
|||
import tachiyomi.data.track.TrackRepositoryImpl
|
||||
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||
import tachiyomi.domain.category.interactor.CreateSubcategory
|
||||
import tachiyomi.domain.category.interactor.DeleteCategory
|
||||
import tachiyomi.domain.category.interactor.DeleteSubcategory
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.RenameCategory
|
||||
import tachiyomi.domain.category.interactor.ReorderCategory
|
||||
|
@ -91,6 +93,8 @@ class DomainModule : InjektModule {
|
|||
addFactory { ReorderCategory(get()) }
|
||||
addFactory { UpdateCategory(get()) }
|
||||
addFactory { DeleteCategory(get()) }
|
||||
addFactory { CreateSubcategory(get()) }
|
||||
addFactory { DeleteSubcategory(get()) }
|
||||
|
||||
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
|
||||
addFactory { GetDuplicateLibraryManga(get()) }
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -2,17 +2,23 @@ package eu.kanade.presentation.library.components
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
@ -29,6 +35,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
@ -105,6 +112,31 @@ fun MangaCompactGridItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MangaCompactSubcategoryItem(
|
||||
title: String? = null,
|
||||
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||
remainingCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
isSelected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
GridItemSelectable(
|
||||
isSelected = isSelected,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
SubcategoryGridCover(
|
||||
modifier = modifier,
|
||||
covers = covers,
|
||||
remainingCount = remainingCount,
|
||||
) {
|
||||
if (title != null) CoverTextOverlay(title = title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title overlay for [MangaCompactGridItem]
|
||||
*/
|
||||
|
@ -211,6 +243,36 @@ fun MangaComfortableGridItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MangaComfortableSubcategoryItem(
|
||||
title: String,
|
||||
titleMaxLines: Int = 2,
|
||||
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||
remainingCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
isSelected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
GridItemSelectable(
|
||||
isSelected = isSelected,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
Column {
|
||||
SubcategoryGridCover(modifier = modifier, covers = covers, remainingCount = remainingCount)
|
||||
|
||||
GridItemTitle(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
title = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
minLines = 2,
|
||||
maxLines = titleMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common cover layout to add contents to be drawn on top of the cover.
|
||||
*/
|
||||
|
@ -249,6 +311,63 @@ private fun MangaGridCover(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubcategoryGridCover(
|
||||
modifier: Modifier = Modifier,
|
||||
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||
remainingCount: Int,
|
||||
content: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
MangaGridCover(
|
||||
modifier,
|
||||
cover = {
|
||||
if (covers.size <= 2) {
|
||||
Column(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
MangaCover.Book(
|
||||
data = covers[0],
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.requiredHeight(4.dp))
|
||||
|
||||
if (covers.size == 2) {
|
||||
MangaCover.Book(
|
||||
data = covers[1],
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small.copy(bottomEnd = CornerSize(4.dp), bottomStart = CornerSize(4.dp)))
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.clipToBounds()
|
||||
.height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
maxItemsInEachRow = 2,
|
||||
) {
|
||||
for (cover in covers) {
|
||||
MangaCover.Book(
|
||||
data = cover,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
badgesEnd = {
|
||||
if (remainingCount > 0) SubcategoryItemCountBadge(count = remainingCount)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridItemTitle(
|
||||
modifier: Modifier,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
import tachiyomi.domain.manga.model.asMangaCover
|
||||
|
||||
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
|
||||
|
||||
@Composable
|
||||
internal fun LibraryComfortableGrid(
|
||||
items: List<LibraryItem>,
|
||||
subcategories: List<Pair<Category, List<LibraryItem>>>,
|
||||
columns: Int,
|
||||
contentPadding: PaddingValues,
|
||||
selection: List<LibraryManga>,
|
||||
|
@ -29,6 +34,23 @@ internal fun LibraryComfortableGrid(
|
|||
) {
|
||||
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
||||
|
||||
items(
|
||||
items = subcategories,
|
||||
contentType = { "library_comfortable_grid_subcategory" },
|
||||
) { (category, libraryItem) ->
|
||||
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
|
||||
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
|
||||
|
||||
MangaComfortableSubcategoryItem(
|
||||
title = category.name,
|
||||
covers = covers,
|
||||
remainingCount = remainingCount,
|
||||
isSelected = if (selection.isNotEmpty()) selection.contains(libraryItem[0].libraryManga) else false,
|
||||
onClick = { onClick(libraryItem[0].libraryManga) },
|
||||
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = items,
|
||||
contentType = { "library_comfortable_grid_item" },
|
||||
|
|
|
@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
import tachiyomi.domain.manga.model.asMangaCover
|
||||
|
||||
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
|
||||
|
||||
@Composable
|
||||
internal fun LibraryCompactGrid(
|
||||
items: List<LibraryItem>,
|
||||
subcategories: List<Pair<Category, List<LibraryItem>>>,
|
||||
showTitle: Boolean,
|
||||
columns: Int,
|
||||
contentPadding: PaddingValues,
|
||||
|
@ -30,6 +35,29 @@ internal fun LibraryCompactGrid(
|
|||
) {
|
||||
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
||||
|
||||
items(
|
||||
items = subcategories,
|
||||
contentType = { "library_compact_grid_subcategory" },
|
||||
) { (category, libraryItem) ->
|
||||
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
|
||||
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
|
||||
|
||||
val isSelected = if (selection.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
selection.fastAny { it.category == libraryItem[0].libraryManga.category }
|
||||
}
|
||||
|
||||
MangaCompactSubcategoryItem(
|
||||
title = category.name.takeIf { showTitle },
|
||||
covers = covers,
|
||||
remainingCount = remainingCount,
|
||||
isSelected = isSelected,
|
||||
onClick = { onClick(libraryItem[0].libraryManga) },
|
||||
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = items,
|
||||
contentType = { "library_compact_grid_item" },
|
||||
|
|
|
@ -35,15 +35,16 @@ fun LibraryContent(
|
|||
hasActiveFilters: Boolean,
|
||||
showPageTabs: Boolean,
|
||||
onChangeCurrentPage: (Int) -> Unit,
|
||||
onMangaClicked: (Long) -> Unit,
|
||||
onMangaClicked: (LibraryManga) -> Unit,
|
||||
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
||||
onToggleSelection: (LibraryManga) -> Unit,
|
||||
onToggleRangeSelection: (LibraryManga) -> Unit,
|
||||
onRefresh: (Category?) -> Boolean,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
||||
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
|
||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
|
||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||
) {
|
||||
Column(
|
||||
|
@ -53,7 +54,7 @@ fun LibraryContent(
|
|||
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
),
|
||||
) {
|
||||
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
|
||||
val coercedCurrentPage = remember(categories) { currentPage().coerceAtMost(categories.lastIndex) }
|
||||
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
@ -68,14 +69,14 @@ fun LibraryContent(
|
|||
LibraryTabs(
|
||||
categories = categories,
|
||||
pagerState = pagerState,
|
||||
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
|
||||
getSubcategoryAndMangaCountForCategory = getSubcategoryAndMangaCountForCategory,
|
||||
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
||||
}
|
||||
|
||||
val notSelectionMode = selection.isEmpty()
|
||||
val onClickManga = { manga: LibraryManga ->
|
||||
if (notSelectionMode) {
|
||||
onMangaClicked(manga.manga.id)
|
||||
onMangaClicked(manga)
|
||||
} else {
|
||||
onToggleSelection(manga)
|
||||
}
|
||||
|
@ -105,6 +106,7 @@ fun LibraryContent(
|
|||
getDisplayMode = getDisplayMode,
|
||||
getColumnsForOrientation = getColumnsForOrientation,
|
||||
getLibraryForPage = getLibraryForPage,
|
||||
getSubcategoriesForPage = getSubcategoriesForPage,
|
||||
onClickManga = onClickManga,
|
||||
onLongClickManga = onToggleRangeSelection,
|
||||
onClickContinueReading = onContinueReadingClicked,
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
|
|||
import eu.kanade.core.preference.PreferenceMutableState
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.presentation.core.components.HorizontalPager
|
||||
|
@ -36,6 +37,7 @@ fun LibraryPager(
|
|||
onGlobalSearchClicked: () -> Unit,
|
||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
|
||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||
onClickManga: (LibraryManga) -> Unit,
|
||||
onLongClickManga: (LibraryManga) -> Unit,
|
||||
|
@ -51,8 +53,9 @@ fun LibraryPager(
|
|||
return@HorizontalPager
|
||||
}
|
||||
val library = getLibraryForPage(page)
|
||||
val subcategories = getSubcategoriesForPage(page)
|
||||
|
||||
if (library.isEmpty()) {
|
||||
if (library.isEmpty() && subcategories.isEmpty()) {
|
||||
LibraryPagerEmptyScreen(
|
||||
searchQuery = searchQuery,
|
||||
hasActiveFilters = hasActiveFilters,
|
||||
|
@ -88,6 +91,7 @@ fun LibraryPager(
|
|||
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
|
||||
LibraryCompactGrid(
|
||||
items = library,
|
||||
subcategories = subcategories,
|
||||
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
|
||||
columns = columns,
|
||||
contentPadding = contentPadding,
|
||||
|
@ -102,6 +106,7 @@ fun LibraryPager(
|
|||
LibraryDisplayMode.ComfortableGrid -> {
|
||||
LibraryComfortableGrid(
|
||||
items = library,
|
||||
subcategories = subcategories,
|
||||
columns = columns,
|
||||
contentPadding = contentPadding,
|
||||
selection = selectedManga,
|
||||
|
|
|
@ -17,7 +17,7 @@ import tachiyomi.presentation.core.components.material.TabText
|
|||
internal fun LibraryTabs(
|
||||
categories: List<Category>,
|
||||
pagerState: PagerState,
|
||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
||||
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
|
||||
onTabItemClick: (Int) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
|
@ -34,9 +34,21 @@ internal fun LibraryTabs(
|
|||
selected = pagerState.currentPage == index,
|
||||
onClick = { onTabItemClick(index) },
|
||||
text = {
|
||||
val (subcategoryCount, mangaCount) = getSubcategoryAndMangaCountForCategory(category)
|
||||
|
||||
val badgeText = if (subcategoryCount != null && mangaCount != null) {
|
||||
"($subcategoryCount, $mangaCount)"
|
||||
} else if (mangaCount != null) {
|
||||
"$mangaCount"
|
||||
} else if (subcategoryCount != null) {
|
||||
"$subcategoryCount"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
TabText(
|
||||
text = category.visualName,
|
||||
badgeCount = getNumberOfMangaForCategory(category),
|
||||
badgeText = badgeText,
|
||||
)
|
||||
},
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,6 @@ import tachiyomi.data.Mangas
|
|||
import tachiyomi.data.UpdateStrategyColumnAdapter
|
||||
import tachiyomi.domain.backup.service.BackupPreferences
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.history.interactor.GetHistory
|
||||
import tachiyomi.domain.history.model.HistoryUpdate
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
|
@ -152,9 +151,7 @@ class BackupManager(
|
|||
suspend fun backupCategories(options: Int): List<BackupCategory> {
|
||||
// Check if user wants category information in backup
|
||||
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||
getCategories.await()
|
||||
.filterNot(Category::isSystemCategory)
|
||||
.map(backupCategoryMapper)
|
||||
handler.awaitList { categoriesQueries.getCategoriesWithGroupedParent(backupCategoryMapper) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
@ -249,13 +246,27 @@ class BackupManager(
|
|||
// Get categories from file and from db
|
||||
val dbCategories = getCategories.await()
|
||||
|
||||
val missingSubcategory = hashMapOf<Long, List<Long>>()
|
||||
|
||||
val categories = backupCategories.map {
|
||||
val missingParent = it.parent.toMutableList()
|
||||
|
||||
var category = it.getCategory()
|
||||
var found = false
|
||||
for (dbCategory in dbCategories) {
|
||||
val isInSameParent = if (it.parent.isEmpty()) true else it.parent.contains(dbCategory.parent)
|
||||
|
||||
// If the category is already in the db, assign the id to the file's category
|
||||
// and do nothing
|
||||
if (category.name == dbCategory.name) {
|
||||
// Same name can be used in categories with different parent
|
||||
// so name alone isn't enough
|
||||
if (category.name == dbCategory.name && isInSameParent) {
|
||||
val parentIds = handler.awaitList {
|
||||
subcategoriesQueries.getParentForChild(dbCategory.id)
|
||||
}
|
||||
|
||||
missingParent.removeAll(parentIds)
|
||||
|
||||
category = category.copy(id = dbCategory.id)
|
||||
found = true
|
||||
break
|
||||
|
@ -270,9 +281,17 @@ class BackupManager(
|
|||
category = category.copy(id = id)
|
||||
}
|
||||
|
||||
missingSubcategory[category.id] = missingParent
|
||||
|
||||
category
|
||||
}
|
||||
|
||||
handler.await(inTransaction = true) {
|
||||
missingSubcategory
|
||||
.flatMap { it.value.map { parent -> it.key to parent } }
|
||||
.forEach { subcategoriesQueries.insert(it.second, it.first) }
|
||||
}
|
||||
|
||||
libraryPreferences.categorizedDisplaySettings().set(
|
||||
(dbCategories + categories)
|
||||
.distinctBy { it.flags }
|
||||
|
|
|
@ -8,6 +8,7 @@ import tachiyomi.domain.category.model.Category
|
|||
class BackupCategory(
|
||||
@ProtoNumber(1) var name: String,
|
||||
@ProtoNumber(2) var order: Long = 0,
|
||||
@ProtoNumber(3) var parent: List<Long> = emptyList(),
|
||||
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var flags: Long = 0,
|
||||
|
@ -18,14 +19,16 @@ class BackupCategory(
|
|||
name = this@BackupCategory.name,
|
||||
flags = this@BackupCategory.flags,
|
||||
order = this@BackupCategory.order,
|
||||
parent = -1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val backupCategoryMapper = { category: Category ->
|
||||
val backupCategoryMapper: (Long, String, Long, Long, String?) -> BackupCategory = { id, name, order, flags, parent ->
|
||||
BackupCategory(
|
||||
name = category.name,
|
||||
order = category.order,
|
||||
flags = category.flags,
|
||||
name = name,
|
||||
order = order,
|
||||
flags = flags,
|
||||
parent = parent?.split(",".toRegex())?.map { it.toLong() } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -44,6 +44,10 @@ import tachiyomi.core.preference.TriState
|
|||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||
import tachiyomi.domain.category.interactor.CreateSubcategory
|
||||
import tachiyomi.domain.category.interactor.DeleteSubcategory
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
|
@ -78,6 +82,9 @@ class LibraryScreenModel(
|
|||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||
private val createCategory: CreateCategoryWithName = Injekt.get(),
|
||||
private val createSubcategory: CreateSubcategory = Injekt.get(),
|
||||
private val deleteSubcategory: DeleteSubcategory = Injekt.get(),
|
||||
private val getChaptersByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
|
@ -471,7 +478,7 @@ class LibraryScreenModel(
|
|||
* @param deleteFromLibrary whether to delete manga from library.
|
||||
* @param deleteChapters whether to delete downloaded chapters.
|
||||
*/
|
||||
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||
fun removeItems(mangaList: List<Manga>, categories: List<Long>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||
coroutineScope.launchNonCancellable {
|
||||
val mangaToDelete = mangaList.distinctBy { it.id }
|
||||
|
||||
|
@ -494,6 +501,8 @@ class LibraryScreenModel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteFromLibrary) deleteSubcategory.await(categories)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -627,11 +636,22 @@ class LibraryScreenModel(
|
|||
|
||||
fun openChangeCategoryDialog() {
|
||||
coroutineScope.launchIO {
|
||||
// Create a copy of selected manga
|
||||
val mangaList = state.value.selection.map { it.manga }
|
||||
val mainSelection = state.value.selection.filter { item ->
|
||||
state.value.categories.any { it.id == item.category }
|
||||
}
|
||||
|
||||
// Hide the default category because it has a different behavior than the ones from db.
|
||||
val categories = state.value.categories.filter { it.id != 0L }
|
||||
val subcategories = state.value.selection
|
||||
.minus(mainSelection.toSet())
|
||||
.map { item -> state.value.library.keys.first { it.id == item.category } }
|
||||
|
||||
// Create a copy of selected manga
|
||||
val mangaList = mainSelection.map { it.manga }
|
||||
|
||||
val withSubcategory = mangaList.isNotEmpty()
|
||||
|
||||
val categories = state.value.library.keys
|
||||
.filter { !it.isSystemCategory && if (!withSubcategory) state.value.categories.contains(it) else true }
|
||||
.sortedBy { if (it.isSubcategory) 1 else 0 }
|
||||
|
||||
// Get indexes of the common categories to preselect.
|
||||
val common = getCommonCategories(mangaList)
|
||||
|
@ -644,23 +664,84 @@ class LibraryScreenModel(
|
|||
else -> CheckboxState.State.None(it)
|
||||
}
|
||||
}
|
||||
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
|
||||
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, subcategories, preselected)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openDeleteMangaDialog() {
|
||||
val mangaList = state.value.selection.map { it.manga }
|
||||
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
|
||||
// Items part of the main categories but not in the subcategories
|
||||
val mainItems = state.value.selection.filter { manga ->
|
||||
state.value.categories.fastAny { it.id == manga.category }
|
||||
}
|
||||
|
||||
val subcategories = state.value.selection
|
||||
.minus(mainItems.toSet())
|
||||
.map { it.category }
|
||||
|
||||
val mangaList = mainItems.map { it.manga }
|
||||
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList, subcategories)) }
|
||||
}
|
||||
|
||||
fun closeDialog() {
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
fun openSubcategoryCreateDialog() {
|
||||
val mangaIds = state.value.selection.map { it.manga }
|
||||
mutableState.update { it.copy(dialog = Dialog.CreateSubcategory(mangaIds)) }
|
||||
}
|
||||
|
||||
fun createSubcategory(name: String, parent: Long, mangas: List<Manga>) {
|
||||
coroutineScope.launchIO {
|
||||
val createResult = createCategory.await(name, isSubcategory = true)
|
||||
|
||||
if (createResult is CreateCategoryWithName.Result.Success) {
|
||||
createSubcategory.await(parent, createResult.id)
|
||||
setMangaCategories(mangas, listOf(createResult.id), emptyList())
|
||||
} else {
|
||||
logcat { "Error creating subcategory with error $createResult" }
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
fun enterSubcategoryForItem(item: LibraryManga): Boolean {
|
||||
return if (state.value.categories.fastAny { it.id == item.category }) {
|
||||
false
|
||||
} else {
|
||||
mutableState.update {
|
||||
val newState = it.copy(parentCategory = item.parentCategory)
|
||||
|
||||
activeCategoryIndex = newState.categories.indexOfFirst { it.id == item.category }
|
||||
|
||||
newState
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveCurrentSubcategory() {
|
||||
val parentCategory = state.value.parentCategory
|
||||
|
||||
if (parentCategory != null) {
|
||||
val category = state.value.library.keys.first { it.id == parentCategory }
|
||||
|
||||
mutableState.update {
|
||||
val newState = it.copy(parentCategory = if (category.isSubcategory) category.parent else null)
|
||||
|
||||
activeCategoryIndex = newState.categories.indexOf(category)
|
||||
|
||||
newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Dialog {
|
||||
data object SettingsSheet : Dialog
|
||||
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
||||
data class DeleteManga(val manga: List<Manga>) : Dialog
|
||||
data class ChangeCategory(val manga: List<Manga>, val category: List<Category>, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
||||
data class DeleteManga(val manga: List<Manga>, val category: List<Long>) : Dialog
|
||||
data class CreateSubcategory(val manga: List<Manga>) : Dialog
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
@ -681,6 +762,7 @@ class LibraryScreenModel(
|
|||
data class State(
|
||||
val isLoading: Boolean = true,
|
||||
val library: LibraryMap = emptyMap(),
|
||||
val parentCategory: Long? = null,
|
||||
val searchQuery: String? = null,
|
||||
val selection: List<LibraryManga> = emptyList(),
|
||||
val hasActiveFilters: Boolean = false,
|
||||
|
@ -690,40 +772,75 @@ class LibraryScreenModel(
|
|||
val dialog: Dialog? = null,
|
||||
) {
|
||||
private val libraryCount by lazy {
|
||||
library.values
|
||||
val distinctItems = library.values
|
||||
.flatten()
|
||||
.fastDistinctBy { it.libraryManga.manga.id }
|
||||
.size
|
||||
|
||||
if (parentCategory == null) {
|
||||
distinctItems.size
|
||||
} else {
|
||||
val subCategories = getAllSubcategoriesForParent()
|
||||
|
||||
distinctItems.count { subCategories.contains(it.libraryManga.category) }
|
||||
}
|
||||
}
|
||||
|
||||
val isLibraryEmpty by lazy { libraryCount == 0 }
|
||||
|
||||
val selectionMode = selection.isNotEmpty()
|
||||
|
||||
val categories = library.keys.toList()
|
||||
val categories = if (parentCategory == null) {
|
||||
library.keys.filter { !it.isSubcategory }
|
||||
} else {
|
||||
library.keys.filter { it.parent == parentCategory }
|
||||
}
|
||||
|
||||
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
|
||||
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
|
||||
}
|
||||
|
||||
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
|
||||
return library.values.toTypedArray().getOrNull(page).orEmpty()
|
||||
return library.getOrDefault(categories[page], emptyList())
|
||||
// return if(parentCategory == null) library.values.toTypedArray().getOrNull(page).orEmpty()
|
||||
// else {
|
||||
// val category = mainCategories[page]
|
||||
// library.getOrDefault(category, emptyList())
|
||||
// }
|
||||
}
|
||||
|
||||
fun getMangaCountForCategory(category: Category): Int? {
|
||||
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null
|
||||
}
|
||||
|
||||
fun getSubcategoryCountForCategory(id: Long): Int? {
|
||||
return if (showMangaCount || !searchQuery.isNullOrEmpty()) {
|
||||
val count = getSubcategoriesForCategory(id).size
|
||||
|
||||
if (count == 0) null else count
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getToolbarTitle(
|
||||
defaultTitle: String,
|
||||
defaultCategoryTitle: String,
|
||||
page: Int,
|
||||
): LibraryToolbarTitle {
|
||||
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
||||
|
||||
val categoryName = category.let {
|
||||
if (it.isSystemCategory) defaultCategoryTitle else it.name
|
||||
}
|
||||
val title = if (showCategoryTabs) defaultTitle else categoryName
|
||||
|
||||
val title = if (showCategoryTabs && category.isSubcategory) {
|
||||
library.keys.first { it.id == parentCategory }.name
|
||||
} else if (showCategoryTabs) {
|
||||
defaultTitle
|
||||
} else {
|
||||
categoryName
|
||||
}
|
||||
|
||||
val count = when {
|
||||
!showMangaCount -> null
|
||||
!showCategoryTabs -> getMangaCountForCategory(category)
|
||||
|
@ -731,7 +848,53 @@ class LibraryScreenModel(
|
|||
else -> libraryCount
|
||||
}
|
||||
|
||||
return LibraryToolbarTitle(title, count)
|
||||
val subcategoryCount = when {
|
||||
!showMangaCount -> null
|
||||
!showCategoryTabs -> getSubcategoryCountForCategory(category.id)
|
||||
category.isSubcategory -> getSubcategoryCountForCategory(category.parent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
return LibraryToolbarTitle(title, count, subcategoryCount)
|
||||
}
|
||||
|
||||
fun getSubcategoriesWithItemsForPage(index: Int): List<Pair<Category, List<LibraryItem>>> {
|
||||
val id = categories[index].id
|
||||
|
||||
return library
|
||||
.filter { (key, value) -> key.parent == id && value.isNotEmpty() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getSubcategoriesForCategory(id: Long): List<Category> {
|
||||
return library.keys.filter { it.parent == id }
|
||||
}
|
||||
|
||||
fun canCreateSubcategory(withSize: Boolean = true): Boolean {
|
||||
if (withSize && selection.size < 2) return false
|
||||
|
||||
// All selections must be in the same category
|
||||
val byCategory = selection.fastDistinctBy { it.category }
|
||||
|
||||
if (byCategory.size != 1) return false
|
||||
|
||||
return categories.fastAny { it.id == byCategory[0].category }
|
||||
}
|
||||
|
||||
/**
|
||||
* All subcategories inside this parent,
|
||||
* both the ones directly under it and the subcategories under them
|
||||
*/
|
||||
private fun getAllSubcategoriesForParent(): List<Long> {
|
||||
val allChild = mutableListOf<Long>().apply { addAll(categories.map { it.id }) }
|
||||
|
||||
var index = 0 // allChild.size
|
||||
while (index < allChild.size) {
|
||||
allChild.addAll(getSubcategoriesForCategory(allChild[index]).map { it.id })
|
||||
index++
|
||||
}
|
||||
|
||||
return allChild
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?,
|
|||
)
|
||||
}
|
||||
|
||||
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long?, Long, Double, Long, Long, Long, Double, Long) -> LibraryManga =
|
||||
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category ->
|
||||
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long?, Long, Double, Long, Long, Long, Double, Long, Long) -> LibraryManga =
|
||||
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category, parentCategory ->
|
||||
LibraryManga(
|
||||
manga = mangaMapper(
|
||||
id,
|
||||
|
@ -60,6 +60,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
|
|||
favoriteModifiedAt,
|
||||
),
|
||||
category = category,
|
||||
parentCategory = parentCategory,
|
||||
totalChapters = totalCount,
|
||||
readCount = readCount.toLong(),
|
||||
bookmarkCount = bookmarkCount.toLong(),
|
||||
|
|
|
@ -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);
|
||||
|
|
29
data/src/main/sqldelight/tachiyomi/data/subcategories.sq
Normal file
29
data/src/main/sqldelight/tachiyomi/data/subcategories.sq
Normal 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 = ?;
|
67
data/src/main/sqldelight/tachiyomi/migrations/26.sqm
Normal file
67
data/src/main/sqldelight/tachiyomi/migrations/26.sqm
Normal 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;
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -18,19 +18,32 @@ class CreateCategoryWithName(
|
|||
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 nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -25,4 +25,12 @@ interface CategoryRepository {
|
|||
suspend fun updateAllFlags(flags: Long?)
|
||||
|
||||
suspend fun delete(categoryId: Long)
|
||||
|
||||
suspend fun getMaxSubcategoryOrder(): Long
|
||||
|
||||
suspend fun lastInsertedId(): Long
|
||||
|
||||
suspend fun insertSubcategory(parent: Long, child: Long)
|
||||
|
||||
suspend fun deleteSubcategories(categories: List<Long>)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -105,6 +105,9 @@
|
|||
<string name="action_open_in_browser">Open in browser</string>
|
||||
<string name="action_show_manga">Show entry</string>
|
||||
<string name="action_copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="action_create_subcategory">Create subcategory</string>
|
||||
<string name="action_add_subcategory">Add subcategory</string>
|
||||
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="action_open_in_web_view">Open in WebView</string>
|
||||
<string name="action_web_view" translatable="false">WebView</string>
|
||||
|
@ -748,6 +751,7 @@
|
|||
|
||||
<!-- Category activity -->
|
||||
<string name="error_category_exists">A category with this name already exists!</string>
|
||||
<string name="error_subcategory_exists">A subcategory with this name already exists!</string>
|
||||
<!-- missing undo feature after Compose rewrite #7454 -->
|
||||
<string name="snack_categories_deleted">Categories deleted</string>
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Reference in a new issue