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.track.TrackRepositoryImpl
|
||||||
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
||||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||||
|
import tachiyomi.domain.category.interactor.CreateSubcategory
|
||||||
import tachiyomi.domain.category.interactor.DeleteCategory
|
import tachiyomi.domain.category.interactor.DeleteCategory
|
||||||
|
import tachiyomi.domain.category.interactor.DeleteSubcategory
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.RenameCategory
|
import tachiyomi.domain.category.interactor.RenameCategory
|
||||||
import tachiyomi.domain.category.interactor.ReorderCategory
|
import tachiyomi.domain.category.interactor.ReorderCategory
|
||||||
|
@ -91,6 +93,8 @@ class DomainModule : InjektModule {
|
||||||
addFactory { ReorderCategory(get()) }
|
addFactory { ReorderCategory(get()) }
|
||||||
addFactory { UpdateCategory(get()) }
|
addFactory { UpdateCategory(get()) }
|
||||||
addFactory { DeleteCategory(get()) }
|
addFactory { DeleteCategory(get()) }
|
||||||
|
addFactory { CreateSubcategory(get()) }
|
||||||
|
addFactory { DeleteSubcategory(get()) }
|
||||||
|
|
||||||
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
|
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
|
||||||
addFactory { GetDuplicateLibraryManga(get()) }
|
addFactory { GetDuplicateLibraryManga(get()) }
|
||||||
|
|
|
@ -75,7 +75,7 @@ fun TabbedScreen(
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.currentPage == index,
|
selected = state.currentPage == index,
|
||||||
onClick = { scope.launch { state.animateScrollToPage(index) } },
|
onClick = { scope.launch { state.animateScrollToPage(index) } },
|
||||||
text = { TabText(text = stringResource(tab.titleRes), badgeCount = tab.badgeNumber) },
|
text = { TabText(text = stringResource(tab.titleRes), badgeText = tab.badgeNumber?.toString()) },
|
||||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredHeight
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
@ -29,6 +35,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
@ -105,6 +112,31 @@ fun MangaCompactGridItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MangaCompactSubcategoryItem(
|
||||||
|
title: String? = null,
|
||||||
|
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||||
|
remainingCount: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
GridItemSelectable(
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
SubcategoryGridCover(
|
||||||
|
modifier = modifier,
|
||||||
|
covers = covers,
|
||||||
|
remainingCount = remainingCount,
|
||||||
|
) {
|
||||||
|
if (title != null) CoverTextOverlay(title = title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Title overlay for [MangaCompactGridItem]
|
* Title overlay for [MangaCompactGridItem]
|
||||||
*/
|
*/
|
||||||
|
@ -211,6 +243,36 @@ fun MangaComfortableGridItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MangaComfortableSubcategoryItem(
|
||||||
|
title: String,
|
||||||
|
titleMaxLines: Int = 2,
|
||||||
|
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||||
|
remainingCount: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
GridItemSelectable(
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
SubcategoryGridCover(modifier = modifier, covers = covers, remainingCount = remainingCount)
|
||||||
|
|
||||||
|
GridItemTitle(
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
title = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = titleMaxLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common cover layout to add contents to be drawn on top of the cover.
|
* Common cover layout to add contents to be drawn on top of the cover.
|
||||||
*/
|
*/
|
||||||
|
@ -249,6 +311,63 @@ private fun MangaGridCover(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubcategoryGridCover(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
covers: List<tachiyomi.domain.manga.model.MangaCover>,
|
||||||
|
remainingCount: Int,
|
||||||
|
content: (@Composable BoxScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
MangaGridCover(
|
||||||
|
modifier,
|
||||||
|
cover = {
|
||||||
|
if (covers.size <= 2) {
|
||||||
|
Column(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||||
|
MangaCover.Book(
|
||||||
|
data = covers[0],
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.requiredHeight(4.dp))
|
||||||
|
|
||||||
|
if (covers.size == 2) {
|
||||||
|
MangaCover.Book(
|
||||||
|
data = covers[1],
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small.copy(bottomEnd = CornerSize(4.dp), bottomStart = CornerSize(4.dp)))
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
maxItemsInEachRow = 2,
|
||||||
|
) {
|
||||||
|
for (cover in covers) {
|
||||||
|
MangaCover.Book(
|
||||||
|
data = cover,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
badgesEnd = {
|
||||||
|
if (remainingCount > 0) SubcategoryItemCountBadge(count = remainingCount)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GridItemTitle(
|
private fun GridItemTitle(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
|
|
@ -47,6 +47,15 @@ internal fun LanguageBadge(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SubcategoryItemCountBadge(count: Int) {
|
||||||
|
Badge(
|
||||||
|
text = "+$count",
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ThemePreviews
|
@ThemePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun BadgePreview() {
|
private fun BadgePreview() {
|
||||||
|
|
|
@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.util.fastAny
|
import androidx.compose.ui.util.fastAny
|
||||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||||
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.model.MangaCover
|
import tachiyomi.domain.manga.model.MangaCover
|
||||||
|
import tachiyomi.domain.manga.model.asMangaCover
|
||||||
|
|
||||||
|
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LibraryComfortableGrid(
|
internal fun LibraryComfortableGrid(
|
||||||
items: List<LibraryItem>,
|
items: List<LibraryItem>,
|
||||||
|
subcategories: List<Pair<Category, List<LibraryItem>>>,
|
||||||
columns: Int,
|
columns: Int,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
selection: List<LibraryManga>,
|
selection: List<LibraryManga>,
|
||||||
|
@ -29,6 +34,23 @@ internal fun LibraryComfortableGrid(
|
||||||
) {
|
) {
|
||||||
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
||||||
|
|
||||||
|
items(
|
||||||
|
items = subcategories,
|
||||||
|
contentType = { "library_comfortable_grid_subcategory" },
|
||||||
|
) { (category, libraryItem) ->
|
||||||
|
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
|
||||||
|
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
|
||||||
|
|
||||||
|
MangaComfortableSubcategoryItem(
|
||||||
|
title = category.name,
|
||||||
|
covers = covers,
|
||||||
|
remainingCount = remainingCount,
|
||||||
|
isSelected = if (selection.isNotEmpty()) selection.contains(libraryItem[0].libraryManga) else false,
|
||||||
|
onClick = { onClick(libraryItem[0].libraryManga) },
|
||||||
|
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = items,
|
items = items,
|
||||||
contentType = { "library_comfortable_grid_item" },
|
contentType = { "library_comfortable_grid_item" },
|
||||||
|
|
|
@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.util.fastAny
|
import androidx.compose.ui.util.fastAny
|
||||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||||
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.model.MangaCover
|
import tachiyomi.domain.manga.model.MangaCover
|
||||||
|
import tachiyomi.domain.manga.model.asMangaCover
|
||||||
|
|
||||||
|
private const val MAX_SUBCATEGORY_ITEM_DISPLAY = 4
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LibraryCompactGrid(
|
internal fun LibraryCompactGrid(
|
||||||
items: List<LibraryItem>,
|
items: List<LibraryItem>,
|
||||||
|
subcategories: List<Pair<Category, List<LibraryItem>>>,
|
||||||
showTitle: Boolean,
|
showTitle: Boolean,
|
||||||
columns: Int,
|
columns: Int,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
|
@ -30,6 +35,29 @@ internal fun LibraryCompactGrid(
|
||||||
) {
|
) {
|
||||||
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
globalSearchItem(searchQuery, onGlobalSearchClicked)
|
||||||
|
|
||||||
|
items(
|
||||||
|
items = subcategories,
|
||||||
|
contentType = { "library_compact_grid_subcategory" },
|
||||||
|
) { (category, libraryItem) ->
|
||||||
|
val remainingCount = libraryItem.size - MAX_SUBCATEGORY_ITEM_DISPLAY
|
||||||
|
val covers = libraryItem.take(MAX_SUBCATEGORY_ITEM_DISPLAY).map { it.libraryManga.manga.asMangaCover() }
|
||||||
|
|
||||||
|
val isSelected = if (selection.isEmpty()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
selection.fastAny { it.category == libraryItem[0].libraryManga.category }
|
||||||
|
}
|
||||||
|
|
||||||
|
MangaCompactSubcategoryItem(
|
||||||
|
title = category.name.takeIf { showTitle },
|
||||||
|
covers = covers,
|
||||||
|
remainingCount = remainingCount,
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = { onClick(libraryItem[0].libraryManga) },
|
||||||
|
onLongClick = { onLongClick(libraryItem[0].libraryManga) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = items,
|
items = items,
|
||||||
contentType = { "library_compact_grid_item" },
|
contentType = { "library_compact_grid_item" },
|
||||||
|
|
|
@ -35,15 +35,16 @@ fun LibraryContent(
|
||||||
hasActiveFilters: Boolean,
|
hasActiveFilters: Boolean,
|
||||||
showPageTabs: Boolean,
|
showPageTabs: Boolean,
|
||||||
onChangeCurrentPage: (Int) -> Unit,
|
onChangeCurrentPage: (Int) -> Unit,
|
||||||
onMangaClicked: (Long) -> Unit,
|
onMangaClicked: (LibraryManga) -> Unit,
|
||||||
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
||||||
onToggleSelection: (LibraryManga) -> Unit,
|
onToggleSelection: (LibraryManga) -> Unit,
|
||||||
onToggleRangeSelection: (LibraryManga) -> Unit,
|
onToggleRangeSelection: (LibraryManga) -> Unit,
|
||||||
onRefresh: (Category?) -> Boolean,
|
onRefresh: (Category?) -> Boolean,
|
||||||
onGlobalSearchClicked: () -> Unit,
|
onGlobalSearchClicked: () -> Unit,
|
||||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
|
||||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||||
|
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
|
||||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
@ -53,7 +54,7 @@ fun LibraryContent(
|
||||||
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
|
val coercedCurrentPage = remember(categories) { currentPage().coerceAtMost(categories.lastIndex) }
|
||||||
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
|
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
@ -68,14 +69,14 @@ fun LibraryContent(
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
categories = categories,
|
categories = categories,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
|
getSubcategoryAndMangaCountForCategory = getSubcategoryAndMangaCountForCategory,
|
||||||
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
val notSelectionMode = selection.isEmpty()
|
val notSelectionMode = selection.isEmpty()
|
||||||
val onClickManga = { manga: LibraryManga ->
|
val onClickManga = { manga: LibraryManga ->
|
||||||
if (notSelectionMode) {
|
if (notSelectionMode) {
|
||||||
onMangaClicked(manga.manga.id)
|
onMangaClicked(manga)
|
||||||
} else {
|
} else {
|
||||||
onToggleSelection(manga)
|
onToggleSelection(manga)
|
||||||
}
|
}
|
||||||
|
@ -105,6 +106,7 @@ fun LibraryContent(
|
||||||
getDisplayMode = getDisplayMode,
|
getDisplayMode = getDisplayMode,
|
||||||
getColumnsForOrientation = getColumnsForOrientation,
|
getColumnsForOrientation = getColumnsForOrientation,
|
||||||
getLibraryForPage = getLibraryForPage,
|
getLibraryForPage = getLibraryForPage,
|
||||||
|
getSubcategoriesForPage = getSubcategoriesForPage,
|
||||||
onClickManga = onClickManga,
|
onClickManga = onClickManga,
|
||||||
onLongClickManga = onToggleRangeSelection,
|
onLongClickManga = onToggleRangeSelection,
|
||||||
onClickContinueReading = onContinueReadingClicked,
|
onClickContinueReading = onContinueReadingClicked,
|
||||||
|
|
|
@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import eu.kanade.core.preference.PreferenceMutableState
|
import eu.kanade.core.preference.PreferenceMutableState
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||||
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.presentation.core.components.HorizontalPager
|
import tachiyomi.presentation.core.components.HorizontalPager
|
||||||
|
@ -36,6 +37,7 @@ fun LibraryPager(
|
||||||
onGlobalSearchClicked: () -> Unit,
|
onGlobalSearchClicked: () -> Unit,
|
||||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||||
|
getSubcategoriesForPage: (Int) -> List<Pair<Category, List<LibraryItem>>>,
|
||||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||||
onClickManga: (LibraryManga) -> Unit,
|
onClickManga: (LibraryManga) -> Unit,
|
||||||
onLongClickManga: (LibraryManga) -> Unit,
|
onLongClickManga: (LibraryManga) -> Unit,
|
||||||
|
@ -51,8 +53,9 @@ fun LibraryPager(
|
||||||
return@HorizontalPager
|
return@HorizontalPager
|
||||||
}
|
}
|
||||||
val library = getLibraryForPage(page)
|
val library = getLibraryForPage(page)
|
||||||
|
val subcategories = getSubcategoriesForPage(page)
|
||||||
|
|
||||||
if (library.isEmpty()) {
|
if (library.isEmpty() && subcategories.isEmpty()) {
|
||||||
LibraryPagerEmptyScreen(
|
LibraryPagerEmptyScreen(
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
hasActiveFilters = hasActiveFilters,
|
hasActiveFilters = hasActiveFilters,
|
||||||
|
@ -88,6 +91,7 @@ fun LibraryPager(
|
||||||
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
|
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
|
||||||
LibraryCompactGrid(
|
LibraryCompactGrid(
|
||||||
items = library,
|
items = library,
|
||||||
|
subcategories = subcategories,
|
||||||
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
|
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
|
||||||
columns = columns,
|
columns = columns,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
|
@ -102,6 +106,7 @@ fun LibraryPager(
|
||||||
LibraryDisplayMode.ComfortableGrid -> {
|
LibraryDisplayMode.ComfortableGrid -> {
|
||||||
LibraryComfortableGrid(
|
LibraryComfortableGrid(
|
||||||
items = library,
|
items = library,
|
||||||
|
subcategories = subcategories,
|
||||||
columns = columns,
|
columns = columns,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
selection = selectedManga,
|
selection = selectedManga,
|
||||||
|
|
|
@ -17,7 +17,7 @@ import tachiyomi.presentation.core.components.material.TabText
|
||||||
internal fun LibraryTabs(
|
internal fun LibraryTabs(
|
||||||
categories: List<Category>,
|
categories: List<Category>,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
getSubcategoryAndMangaCountForCategory: (Category) -> Pair<Int?, Int?>,
|
||||||
onTabItemClick: (Int) -> Unit,
|
onTabItemClick: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
@ -34,9 +34,21 @@ internal fun LibraryTabs(
|
||||||
selected = pagerState.currentPage == index,
|
selected = pagerState.currentPage == index,
|
||||||
onClick = { onTabItemClick(index) },
|
onClick = { onTabItemClick(index) },
|
||||||
text = {
|
text = {
|
||||||
|
val (subcategoryCount, mangaCount) = getSubcategoryAndMangaCountForCategory(category)
|
||||||
|
|
||||||
|
val badgeText = if (subcategoryCount != null && mangaCount != null) {
|
||||||
|
"($subcategoryCount, $mangaCount)"
|
||||||
|
} else if (mangaCount != null) {
|
||||||
|
"$mangaCount"
|
||||||
|
} else if (subcategoryCount != null) {
|
||||||
|
"$subcategoryCount"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
TabText(
|
TabText(
|
||||||
text = category.visualName,
|
text = category.visualName,
|
||||||
badgeCount = getNumberOfMangaForCategory(category),
|
badgeText = badgeText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
|
@ -81,9 +81,19 @@ private fun LibraryRegularToolbar(
|
||||||
modifier = Modifier.weight(1f, false),
|
modifier = Modifier.weight(1f, false),
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (title.numberOfManga != null) {
|
|
||||||
|
val (_, mangaCount, subcatCount) = title
|
||||||
|
if (mangaCount != null || subcatCount != null) {
|
||||||
|
val text = if (mangaCount != null && subcatCount != null) {
|
||||||
|
"($subcatCount, $mangaCount)"
|
||||||
|
} else if (mangaCount != null) {
|
||||||
|
"$mangaCount"
|
||||||
|
} else {
|
||||||
|
"$subcatCount"
|
||||||
|
}
|
||||||
|
|
||||||
Pill(
|
Pill(
|
||||||
text = "${title.numberOfManga}",
|
text = text,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
@ -155,4 +165,5 @@ private fun LibrarySelectionToolbar(
|
||||||
data class LibraryToolbarTitle(
|
data class LibraryToolbarTitle(
|
||||||
val text: String,
|
val text: String,
|
||||||
val numberOfManga: Int? = null,
|
val numberOfManga: Int? = null,
|
||||||
|
val subcategoryCount: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
|
@ -29,6 +29,7 @@ import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.DoneAll
|
import androidx.compose.material.icons.outlined.DoneAll
|
||||||
import androidx.compose.material.icons.outlined.Download
|
import androidx.compose.material.icons.outlined.Download
|
||||||
import androidx.compose.material.icons.outlined.Label
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material.icons.outlined.NewLabel
|
||||||
import androidx.compose.material.icons.outlined.RemoveDone
|
import androidx.compose.material.icons.outlined.RemoveDone
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
@ -224,6 +225,7 @@ fun LibraryBottomActionMenu(
|
||||||
onMarkAsUnreadClicked: () -> Unit,
|
onMarkAsUnreadClicked: () -> Unit,
|
||||||
onDownloadClicked: ((DownloadAction) -> Unit)?,
|
onDownloadClicked: ((DownloadAction) -> Unit)?,
|
||||||
onDeleteClicked: () -> Unit,
|
onDeleteClicked: () -> Unit,
|
||||||
|
onCreateSubcategoryClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
|
@ -237,11 +239,11 @@ fun LibraryBottomActionMenu(
|
||||||
tonalElevation = 3.dp,
|
tonalElevation = 3.dp,
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
|
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
|
||||||
var resetJob: Job? = remember { null }
|
var resetJob: Job? = remember { null }
|
||||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
(0..<5).forEach { i -> confirm[i] = i == toConfirmIndex }
|
(0..<6).forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||||
resetJob?.cancel()
|
resetJob?.cancel()
|
||||||
resetJob = scope.launch {
|
resetJob = scope.launch {
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
|
@ -301,6 +303,15 @@ fun LibraryBottomActionMenu(
|
||||||
onLongClick = { onLongClickItem(4) },
|
onLongClick = { onLongClickItem(4) },
|
||||||
onClick = onDeleteClicked,
|
onClick = onDeleteClicked,
|
||||||
)
|
)
|
||||||
|
if (onCreateSubcategoryClick != null) {
|
||||||
|
Button(
|
||||||
|
title = stringResource(R.string.action_create_subcategory),
|
||||||
|
icon = Icons.Outlined.NewLabel,
|
||||||
|
toConfirm = confirm[5],
|
||||||
|
onLongClick = { onLongClickItem(5) },
|
||||||
|
onClick = onCreateSubcategoryClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,6 @@ import tachiyomi.data.Mangas
|
||||||
import tachiyomi.data.UpdateStrategyColumnAdapter
|
import tachiyomi.data.UpdateStrategyColumnAdapter
|
||||||
import tachiyomi.domain.backup.service.BackupPreferences
|
import tachiyomi.domain.backup.service.BackupPreferences
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
import tachiyomi.domain.history.model.HistoryUpdate
|
import tachiyomi.domain.history.model.HistoryUpdate
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
|
@ -152,9 +151,7 @@ class BackupManager(
|
||||||
suspend fun backupCategories(options: Int): List<BackupCategory> {
|
suspend fun backupCategories(options: Int): List<BackupCategory> {
|
||||||
// Check if user wants category information in backup
|
// Check if user wants category information in backup
|
||||||
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||||
getCategories.await()
|
handler.awaitList { categoriesQueries.getCategoriesWithGroupedParent(backupCategoryMapper) }
|
||||||
.filterNot(Category::isSystemCategory)
|
|
||||||
.map(backupCategoryMapper)
|
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
@ -249,13 +246,27 @@ class BackupManager(
|
||||||
// Get categories from file and from db
|
// Get categories from file and from db
|
||||||
val dbCategories = getCategories.await()
|
val dbCategories = getCategories.await()
|
||||||
|
|
||||||
|
val missingSubcategory = hashMapOf<Long, List<Long>>()
|
||||||
|
|
||||||
val categories = backupCategories.map {
|
val categories = backupCategories.map {
|
||||||
|
val missingParent = it.parent.toMutableList()
|
||||||
|
|
||||||
var category = it.getCategory()
|
var category = it.getCategory()
|
||||||
var found = false
|
var found = false
|
||||||
for (dbCategory in dbCategories) {
|
for (dbCategory in dbCategories) {
|
||||||
|
val isInSameParent = if (it.parent.isEmpty()) true else it.parent.contains(dbCategory.parent)
|
||||||
|
|
||||||
// If the category is already in the db, assign the id to the file's category
|
// If the category is already in the db, assign the id to the file's category
|
||||||
// and do nothing
|
// and do nothing
|
||||||
if (category.name == dbCategory.name) {
|
// Same name can be used in categories with different parent
|
||||||
|
// so name alone isn't enough
|
||||||
|
if (category.name == dbCategory.name && isInSameParent) {
|
||||||
|
val parentIds = handler.awaitList {
|
||||||
|
subcategoriesQueries.getParentForChild(dbCategory.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingParent.removeAll(parentIds)
|
||||||
|
|
||||||
category = category.copy(id = dbCategory.id)
|
category = category.copy(id = dbCategory.id)
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
|
@ -270,9 +281,17 @@ class BackupManager(
|
||||||
category = category.copy(id = id)
|
category = category.copy(id = id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
missingSubcategory[category.id] = missingParent
|
||||||
|
|
||||||
category
|
category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handler.await(inTransaction = true) {
|
||||||
|
missingSubcategory
|
||||||
|
.flatMap { it.value.map { parent -> it.key to parent } }
|
||||||
|
.forEach { subcategoriesQueries.insert(it.second, it.first) }
|
||||||
|
}
|
||||||
|
|
||||||
libraryPreferences.categorizedDisplaySettings().set(
|
libraryPreferences.categorizedDisplaySettings().set(
|
||||||
(dbCategories + categories)
|
(dbCategories + categories)
|
||||||
.distinctBy { it.flags }
|
.distinctBy { it.flags }
|
||||||
|
|
|
@ -8,6 +8,7 @@ import tachiyomi.domain.category.model.Category
|
||||||
class BackupCategory(
|
class BackupCategory(
|
||||||
@ProtoNumber(1) var name: String,
|
@ProtoNumber(1) var name: String,
|
||||||
@ProtoNumber(2) var order: Long = 0,
|
@ProtoNumber(2) var order: Long = 0,
|
||||||
|
@ProtoNumber(3) var parent: List<Long> = emptyList(),
|
||||||
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
|
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
|
||||||
// Bump by 100 to specify this is a 0.x value
|
// Bump by 100 to specify this is a 0.x value
|
||||||
@ProtoNumber(100) var flags: Long = 0,
|
@ProtoNumber(100) var flags: Long = 0,
|
||||||
|
@ -18,14 +19,16 @@ class BackupCategory(
|
||||||
name = this@BackupCategory.name,
|
name = this@BackupCategory.name,
|
||||||
flags = this@BackupCategory.flags,
|
flags = this@BackupCategory.flags,
|
||||||
order = this@BackupCategory.order,
|
order = this@BackupCategory.order,
|
||||||
|
parent = -1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val backupCategoryMapper = { category: Category ->
|
val backupCategoryMapper: (Long, String, Long, Long, String?) -> BackupCategory = { id, name, order, flags, parent ->
|
||||||
BackupCategory(
|
BackupCategory(
|
||||||
name = category.name,
|
name = name,
|
||||||
order = category.order,
|
order = order,
|
||||||
flags = category.flags,
|
flags = flags,
|
||||||
|
parent = parent?.split(",".toRegex())?.map { it.toLong() } ?: emptyList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,10 @@ import tachiyomi.core.preference.TriState
|
||||||
import tachiyomi.core.util.lang.launchIO
|
import tachiyomi.core.util.lang.launchIO
|
||||||
import tachiyomi.core.util.lang.launchNonCancellable
|
import tachiyomi.core.util.lang.launchNonCancellable
|
||||||
import tachiyomi.core.util.lang.withIOContext
|
import tachiyomi.core.util.lang.withIOContext
|
||||||
|
import tachiyomi.core.util.system.logcat
|
||||||
|
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||||
|
import tachiyomi.domain.category.interactor.CreateSubcategory
|
||||||
|
import tachiyomi.domain.category.interactor.DeleteSubcategory
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
|
@ -78,6 +82,9 @@ class LibraryScreenModel(
|
||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
||||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||||
|
private val createCategory: CreateCategoryWithName = Injekt.get(),
|
||||||
|
private val createSubcategory: CreateSubcategory = Injekt.get(),
|
||||||
|
private val deleteSubcategory: DeleteSubcategory = Injekt.get(),
|
||||||
private val getChaptersByMangaId: GetChapterByMangaId = Injekt.get(),
|
private val getChaptersByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||||
private val updateManga: UpdateManga = Injekt.get(),
|
private val updateManga: UpdateManga = Injekt.get(),
|
||||||
|
@ -471,7 +478,7 @@ class LibraryScreenModel(
|
||||||
* @param deleteFromLibrary whether to delete manga from library.
|
* @param deleteFromLibrary whether to delete manga from library.
|
||||||
* @param deleteChapters whether to delete downloaded chapters.
|
* @param deleteChapters whether to delete downloaded chapters.
|
||||||
*/
|
*/
|
||||||
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
fun removeItems(mangaList: List<Manga>, categories: List<Long>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||||
coroutineScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
val mangaToDelete = mangaList.distinctBy { it.id }
|
val mangaToDelete = mangaList.distinctBy { it.id }
|
||||||
|
|
||||||
|
@ -494,6 +501,8 @@ class LibraryScreenModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteFromLibrary) deleteSubcategory.await(categories)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -627,11 +636,22 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
fun openChangeCategoryDialog() {
|
fun openChangeCategoryDialog() {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
// Create a copy of selected manga
|
val mainSelection = state.value.selection.filter { item ->
|
||||||
val mangaList = state.value.selection.map { it.manga }
|
state.value.categories.any { it.id == item.category }
|
||||||
|
}
|
||||||
|
|
||||||
// Hide the default category because it has a different behavior than the ones from db.
|
val subcategories = state.value.selection
|
||||||
val categories = state.value.categories.filter { it.id != 0L }
|
.minus(mainSelection.toSet())
|
||||||
|
.map { item -> state.value.library.keys.first { it.id == item.category } }
|
||||||
|
|
||||||
|
// Create a copy of selected manga
|
||||||
|
val mangaList = mainSelection.map { it.manga }
|
||||||
|
|
||||||
|
val withSubcategory = mangaList.isNotEmpty()
|
||||||
|
|
||||||
|
val categories = state.value.library.keys
|
||||||
|
.filter { !it.isSystemCategory && if (!withSubcategory) state.value.categories.contains(it) else true }
|
||||||
|
.sortedBy { if (it.isSubcategory) 1 else 0 }
|
||||||
|
|
||||||
// Get indexes of the common categories to preselect.
|
// Get indexes of the common categories to preselect.
|
||||||
val common = getCommonCategories(mangaList)
|
val common = getCommonCategories(mangaList)
|
||||||
|
@ -644,23 +664,84 @@ class LibraryScreenModel(
|
||||||
else -> CheckboxState.State.None(it)
|
else -> CheckboxState.State.None(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
|
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, subcategories, preselected)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openDeleteMangaDialog() {
|
fun openDeleteMangaDialog() {
|
||||||
val mangaList = state.value.selection.map { it.manga }
|
// Items part of the main categories but not in the subcategories
|
||||||
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
|
val mainItems = state.value.selection.filter { manga ->
|
||||||
|
state.value.categories.fastAny { it.id == manga.category }
|
||||||
|
}
|
||||||
|
|
||||||
|
val subcategories = state.value.selection
|
||||||
|
.minus(mainItems.toSet())
|
||||||
|
.map { it.category }
|
||||||
|
|
||||||
|
val mangaList = mainItems.map { it.manga }
|
||||||
|
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList, subcategories)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeDialog() {
|
fun closeDialog() {
|
||||||
mutableState.update { it.copy(dialog = null) }
|
mutableState.update { it.copy(dialog = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openSubcategoryCreateDialog() {
|
||||||
|
val mangaIds = state.value.selection.map { it.manga }
|
||||||
|
mutableState.update { it.copy(dialog = Dialog.CreateSubcategory(mangaIds)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSubcategory(name: String, parent: Long, mangas: List<Manga>) {
|
||||||
|
coroutineScope.launchIO {
|
||||||
|
val createResult = createCategory.await(name, isSubcategory = true)
|
||||||
|
|
||||||
|
if (createResult is CreateCategoryWithName.Result.Success) {
|
||||||
|
createSubcategory.await(parent, createResult.id)
|
||||||
|
setMangaCategories(mangas, listOf(createResult.id), emptyList())
|
||||||
|
} else {
|
||||||
|
logcat { "Error creating subcategory with error $createResult" }
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enterSubcategoryForItem(item: LibraryManga): Boolean {
|
||||||
|
return if (state.value.categories.fastAny { it.id == item.category }) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
mutableState.update {
|
||||||
|
val newState = it.copy(parentCategory = item.parentCategory)
|
||||||
|
|
||||||
|
activeCategoryIndex = newState.categories.indexOfFirst { it.id == item.category }
|
||||||
|
|
||||||
|
newState
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun leaveCurrentSubcategory() {
|
||||||
|
val parentCategory = state.value.parentCategory
|
||||||
|
|
||||||
|
if (parentCategory != null) {
|
||||||
|
val category = state.value.library.keys.first { it.id == parentCategory }
|
||||||
|
|
||||||
|
mutableState.update {
|
||||||
|
val newState = it.copy(parentCategory = if (category.isSubcategory) category.parent else null)
|
||||||
|
|
||||||
|
activeCategoryIndex = newState.categories.indexOf(category)
|
||||||
|
|
||||||
|
newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data object SettingsSheet : Dialog
|
data object SettingsSheet : Dialog
|
||||||
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
data class ChangeCategory(val manga: List<Manga>, val category: List<Category>, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
||||||
data class DeleteManga(val manga: List<Manga>) : Dialog
|
data class DeleteManga(val manga: List<Manga>, val category: List<Long>) : Dialog
|
||||||
|
data class CreateSubcategory(val manga: List<Manga>) : Dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@ -681,6 +762,7 @@ class LibraryScreenModel(
|
||||||
data class State(
|
data class State(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val library: LibraryMap = emptyMap(),
|
val library: LibraryMap = emptyMap(),
|
||||||
|
val parentCategory: Long? = null,
|
||||||
val searchQuery: String? = null,
|
val searchQuery: String? = null,
|
||||||
val selection: List<LibraryManga> = emptyList(),
|
val selection: List<LibraryManga> = emptyList(),
|
||||||
val hasActiveFilters: Boolean = false,
|
val hasActiveFilters: Boolean = false,
|
||||||
|
@ -690,40 +772,75 @@ class LibraryScreenModel(
|
||||||
val dialog: Dialog? = null,
|
val dialog: Dialog? = null,
|
||||||
) {
|
) {
|
||||||
private val libraryCount by lazy {
|
private val libraryCount by lazy {
|
||||||
library.values
|
val distinctItems = library.values
|
||||||
.flatten()
|
.flatten()
|
||||||
.fastDistinctBy { it.libraryManga.manga.id }
|
.fastDistinctBy { it.libraryManga.manga.id }
|
||||||
.size
|
|
||||||
|
if (parentCategory == null) {
|
||||||
|
distinctItems.size
|
||||||
|
} else {
|
||||||
|
val subCategories = getAllSubcategoriesForParent()
|
||||||
|
|
||||||
|
distinctItems.count { subCategories.contains(it.libraryManga.category) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLibraryEmpty by lazy { libraryCount == 0 }
|
val isLibraryEmpty by lazy { libraryCount == 0 }
|
||||||
|
|
||||||
val selectionMode = selection.isNotEmpty()
|
val selectionMode = selection.isNotEmpty()
|
||||||
|
|
||||||
val categories = library.keys.toList()
|
val categories = if (parentCategory == null) {
|
||||||
|
library.keys.filter { !it.isSubcategory }
|
||||||
|
} else {
|
||||||
|
library.keys.filter { it.parent == parentCategory }
|
||||||
|
}
|
||||||
|
|
||||||
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
|
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
|
||||||
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
|
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
|
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
|
||||||
return library.values.toTypedArray().getOrNull(page).orEmpty()
|
return library.getOrDefault(categories[page], emptyList())
|
||||||
|
// return if(parentCategory == null) library.values.toTypedArray().getOrNull(page).orEmpty()
|
||||||
|
// else {
|
||||||
|
// val category = mainCategories[page]
|
||||||
|
// library.getOrDefault(category, emptyList())
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMangaCountForCategory(category: Category): Int? {
|
fun getMangaCountForCategory(category: Category): Int? {
|
||||||
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null
|
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSubcategoryCountForCategory(id: Long): Int? {
|
||||||
|
return if (showMangaCount || !searchQuery.isNullOrEmpty()) {
|
||||||
|
val count = getSubcategoriesForCategory(id).size
|
||||||
|
|
||||||
|
if (count == 0) null else count
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getToolbarTitle(
|
fun getToolbarTitle(
|
||||||
defaultTitle: String,
|
defaultTitle: String,
|
||||||
defaultCategoryTitle: String,
|
defaultCategoryTitle: String,
|
||||||
page: Int,
|
page: Int,
|
||||||
): LibraryToolbarTitle {
|
): LibraryToolbarTitle {
|
||||||
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
||||||
|
|
||||||
val categoryName = category.let {
|
val categoryName = category.let {
|
||||||
if (it.isSystemCategory) defaultCategoryTitle else it.name
|
if (it.isSystemCategory) defaultCategoryTitle else it.name
|
||||||
}
|
}
|
||||||
val title = if (showCategoryTabs) defaultTitle else categoryName
|
|
||||||
|
val title = if (showCategoryTabs && category.isSubcategory) {
|
||||||
|
library.keys.first { it.id == parentCategory }.name
|
||||||
|
} else if (showCategoryTabs) {
|
||||||
|
defaultTitle
|
||||||
|
} else {
|
||||||
|
categoryName
|
||||||
|
}
|
||||||
|
|
||||||
val count = when {
|
val count = when {
|
||||||
!showMangaCount -> null
|
!showMangaCount -> null
|
||||||
!showCategoryTabs -> getMangaCountForCategory(category)
|
!showCategoryTabs -> getMangaCountForCategory(category)
|
||||||
|
@ -731,7 +848,53 @@ class LibraryScreenModel(
|
||||||
else -> libraryCount
|
else -> libraryCount
|
||||||
}
|
}
|
||||||
|
|
||||||
return LibraryToolbarTitle(title, count)
|
val subcategoryCount = when {
|
||||||
|
!showMangaCount -> null
|
||||||
|
!showCategoryTabs -> getSubcategoryCountForCategory(category.id)
|
||||||
|
category.isSubcategory -> getSubcategoryCountForCategory(category.parent)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return LibraryToolbarTitle(title, count, subcategoryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSubcategoriesWithItemsForPage(index: Int): List<Pair<Category, List<LibraryItem>>> {
|
||||||
|
val id = categories[index].id
|
||||||
|
|
||||||
|
return library
|
||||||
|
.filter { (key, value) -> key.parent == id && value.isNotEmpty() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSubcategoriesForCategory(id: Long): List<Category> {
|
||||||
|
return library.keys.filter { it.parent == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canCreateSubcategory(withSize: Boolean = true): Boolean {
|
||||||
|
if (withSize && selection.size < 2) return false
|
||||||
|
|
||||||
|
// All selections must be in the same category
|
||||||
|
val byCategory = selection.fastDistinctBy { it.category }
|
||||||
|
|
||||||
|
if (byCategory.size != 1) return false
|
||||||
|
|
||||||
|
return categories.fastAny { it.id == byCategory[0].category }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All subcategories inside this parent,
|
||||||
|
* both the ones directly under it and the subcategories under them
|
||||||
|
*/
|
||||||
|
private fun getAllSubcategoriesForParent(): List<Long> {
|
||||||
|
val allChild = mutableListOf<Long>().apply { addAll(categories.map { it.id }) }
|
||||||
|
|
||||||
|
var index = 0 // allChild.size
|
||||||
|
while (index < allChild.size) {
|
||||||
|
allChild.addAll(getSubcategoriesForCategory(allChild[index]).map { it.id })
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
return allChild
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
|
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
|
||||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||||
import eu.kanade.presentation.category.components.ChangeCategoryDialog
|
import eu.kanade.presentation.category.components.ChangeCategoryDialog
|
||||||
|
import eu.kanade.presentation.library.CreateSubcategoryDialog
|
||||||
import eu.kanade.presentation.library.DeleteLibraryMangaDialog
|
import eu.kanade.presentation.library.DeleteLibraryMangaDialog
|
||||||
import eu.kanade.presentation.library.LibrarySettingsDialog
|
import eu.kanade.presentation.library.LibrarySettingsDialog
|
||||||
import eu.kanade.presentation.library.components.LibraryContent
|
import eu.kanade.presentation.library.components.LibraryContent
|
||||||
|
@ -143,6 +144,7 @@ object LibraryTab : Tab {
|
||||||
onDownloadClicked = screenModel::runDownloadActionSelection
|
onDownloadClicked = screenModel::runDownloadActionSelection
|
||||||
.takeIf { state.selection.fastAll { !it.manga.isLocal() } },
|
.takeIf { state.selection.fastAll { !it.manga.isLocal() } },
|
||||||
onDeleteClicked = screenModel::openDeleteMangaDialog,
|
onDeleteClicked = screenModel::openDeleteMangaDialog,
|
||||||
|
onCreateSubcategoryClick = if (state.canCreateSubcategory()) screenModel::openSubcategoryCreateDialog else null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
|
@ -173,7 +175,7 @@ object LibraryTab : Tab {
|
||||||
hasActiveFilters = state.hasActiveFilters,
|
hasActiveFilters = state.hasActiveFilters,
|
||||||
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
|
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
|
||||||
onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
|
onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
|
||||||
onMangaClicked = { navigator.push(MangaScreen(it)) },
|
onMangaClicked = { if (!screenModel.enterSubcategoryForItem(it)) navigator.push(MangaScreen(it.manga.id)) },
|
||||||
onContinueReadingClicked = { it: LibraryManga ->
|
onContinueReadingClicked = { it: LibraryManga ->
|
||||||
scope.launchIO {
|
scope.launchIO {
|
||||||
val chapter = screenModel.getNextUnreadChapter(it.manga)
|
val chapter = screenModel.getNextUnreadChapter(it.manga)
|
||||||
|
@ -194,9 +196,10 @@ object LibraryTab : Tab {
|
||||||
onGlobalSearchClicked = {
|
onGlobalSearchClicked = {
|
||||||
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
|
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
|
||||||
},
|
},
|
||||||
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
|
getSubcategoryAndMangaCountForCategory = { state.getSubcategoryCountForCategory(it.id) to state.getMangaCountForCategory(it) },
|
||||||
getDisplayMode = { screenModel.getDisplayMode() },
|
getDisplayMode = { screenModel.getDisplayMode() },
|
||||||
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
|
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
|
||||||
|
getSubcategoriesForPage = { state.getSubcategoriesWithItemsForPage(it) },
|
||||||
) { state.getLibraryItemsByPage(it) }
|
) { state.getLibraryItemsByPage(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -235,18 +238,28 @@ object LibraryTab : Tab {
|
||||||
containsLocalManga = dialog.manga.any(Manga::isLocal),
|
containsLocalManga = dialog.manga.any(Manga::isLocal),
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onConfirm = { deleteManga, deleteChapter ->
|
onConfirm = { deleteManga, deleteChapter ->
|
||||||
screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter)
|
screenModel.removeItems(dialog.manga, dialog.category, deleteManga, deleteChapter)
|
||||||
screenModel.clearSelection()
|
screenModel.clearSelection()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is LibraryScreenModel.Dialog.CreateSubcategory -> {
|
||||||
|
val parent = state.categories.getOrNull(screenModel.activeCategoryIndex) ?: return
|
||||||
|
|
||||||
|
CreateSubcategoryDialog(
|
||||||
|
categories = state.getSubcategoriesForCategory(parent.id),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onCreate = { screenModel.createSubcategory(it, parent.id, dialog.manga) },
|
||||||
|
)
|
||||||
|
}
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
|
BackHandler(enabled = state.selectionMode || state.searchQuery != null || state.parentCategory != null) {
|
||||||
when {
|
when {
|
||||||
state.selectionMode -> screenModel.clearSelection()
|
state.selectionMode -> screenModel.clearSelection()
|
||||||
state.searchQuery != null -> screenModel.search(null)
|
state.searchQuery != null -> screenModel.search(null)
|
||||||
|
state.parentCategory != null -> screenModel.leaveCurrentSubcategory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,12 @@ package tachiyomi.data.category
|
||||||
|
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
|
|
||||||
val categoryMapper: (Long, String, Long, Long) -> Category = { id, name, order, flags ->
|
val categoryMapper: (Long, String, Long, Long, Long) -> Category = { id, name, order, flags, parent ->
|
||||||
Category(
|
Category(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
order = order,
|
order = order,
|
||||||
flags = flags,
|
flags = flags,
|
||||||
|
parent = parent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 =
|
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long?, Long, Double, Long, Long, Long, Double, Long, Long) -> LibraryManga =
|
||||||
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category ->
|
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, favoriteModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category, parentCategory ->
|
||||||
LibraryManga(
|
LibraryManga(
|
||||||
manga = mangaMapper(
|
manga = mangaMapper(
|
||||||
id,
|
id,
|
||||||
|
@ -60,6 +60,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
|
||||||
favoriteModifiedAt,
|
favoriteModifiedAt,
|
||||||
),
|
),
|
||||||
category = category,
|
category = category,
|
||||||
|
parentCategory = parentCategory,
|
||||||
totalChapters = totalCount,
|
totalChapters = totalCount,
|
||||||
readCount = readCount.toLong(),
|
readCount = readCount.toLong(),
|
||||||
bookmarkCount = bookmarkCount.toLong(),
|
bookmarkCount = bookmarkCount.toLong(),
|
||||||
|
|
|
@ -17,18 +17,21 @@ BEGIN SELECT CASE
|
||||||
END;
|
END;
|
||||||
|
|
||||||
getCategory:
|
getCategory:
|
||||||
SELECT *
|
SELECT *, -1 AS parent
|
||||||
FROM categories
|
FROM categories
|
||||||
WHERE _id = :id
|
WHERE _id = :id
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
getCategories:
|
getCategories:
|
||||||
SELECT
|
SELECT
|
||||||
_id AS id,
|
categories._id AS id,
|
||||||
name,
|
categories.name,
|
||||||
sort AS `order`,
|
categories.sort AS `order`,
|
||||||
flags
|
categories.flags,
|
||||||
|
coalesce(subcategory.parent, -1) AS parent
|
||||||
FROM categories
|
FROM categories
|
||||||
|
LEFT JOIN subcategory
|
||||||
|
ON categories._id = subcategory.child
|
||||||
ORDER BY sort;
|
ORDER BY sort;
|
||||||
|
|
||||||
getCategoriesByMangaId:
|
getCategoriesByMangaId:
|
||||||
|
@ -36,12 +39,35 @@ SELECT
|
||||||
C._id AS id,
|
C._id AS id,
|
||||||
C.name,
|
C.name,
|
||||||
C.sort AS `order`,
|
C.sort AS `order`,
|
||||||
C.flags
|
C.flags,
|
||||||
|
coalesce(SC.parent, -1) AS parent
|
||||||
FROM categories C
|
FROM categories C
|
||||||
|
LEFT JOIN subcategory SC
|
||||||
|
ON C._id = SC.child
|
||||||
JOIN mangas_categories MC
|
JOIN mangas_categories MC
|
||||||
ON C._id = MC.category_id
|
ON C._id = MC.category_id
|
||||||
WHERE MC.manga_id = :mangaId;
|
WHERE MC.manga_id = :mangaId;
|
||||||
|
|
||||||
|
getMaxSubcategoryOrder:
|
||||||
|
SELECT
|
||||||
|
coalesce(max(sort), 0) AS max
|
||||||
|
FROM categories
|
||||||
|
JOIN subcategory
|
||||||
|
ON categories._id = subcategory.child;
|
||||||
|
|
||||||
|
getCategoriesWithGroupedParent:
|
||||||
|
SELECT
|
||||||
|
categories._id AS id,
|
||||||
|
categories.name,
|
||||||
|
categories.sort AS `order`,
|
||||||
|
categories.flags,
|
||||||
|
group_concat(subcategory.parent) AS parent
|
||||||
|
FROM categories
|
||||||
|
LEFT JOIN subcategory
|
||||||
|
ON categories._id = subcategory.child
|
||||||
|
WHERE categories._id IS NOT 0
|
||||||
|
GROUP BY id;
|
||||||
|
|
||||||
insert:
|
insert:
|
||||||
INSERT INTO categories(name, sort, flags)
|
INSERT INTO categories(name, sort, flags)
|
||||||
VALUES (:name, :order, :flags);
|
VALUES (:name, :order, :flags);
|
||||||
|
|
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.fetchedAt, 0) AS chapterFetchedAt,
|
||||||
coalesce(C.lastRead, 0) AS lastRead,
|
coalesce(C.lastRead, 0) AS lastRead,
|
||||||
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
|
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
|
||||||
coalesce(MC.category_id, 0) AS category
|
coalesce(MC.category_id, 0) AS category,
|
||||||
|
coalesce(MC.parent, -1) AS parentCategory
|
||||||
FROM mangas M
|
FROM mangas M
|
||||||
LEFT JOIN(
|
LEFT JOIN(
|
||||||
SELECT
|
SELECT
|
||||||
|
@ -24,7 +25,26 @@ LEFT JOIN(
|
||||||
GROUP BY chapters.manga_id
|
GROUP BY chapters.manga_id
|
||||||
) AS C
|
) AS C
|
||||||
ON M._id = C.manga_id
|
ON M._id = C.manga_id
|
||||||
LEFT JOIN mangas_categories AS MC
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
mc.*,
|
||||||
|
s.parent
|
||||||
|
FROM
|
||||||
|
mangas_categories AS mc
|
||||||
|
LEFT JOIN subcategory AS s
|
||||||
|
ON mc.category_id = s.child
|
||||||
|
WHERE mc._id NOT IN (
|
||||||
|
-- Get ids of mangas_categories that belong to parent category where the same manga_id already exists in a subcategory
|
||||||
|
SELECT DISTINCT
|
||||||
|
mc_temp._id
|
||||||
|
FROM mangas_categories mc_temp
|
||||||
|
JOIN subcategory s_temp
|
||||||
|
ON mc_temp.category_id = s_temp.parent
|
||||||
|
JOIN mangas_categories AS mc_temp_sub
|
||||||
|
ON mc_temp.manga_id = mc_temp_sub.manga_id
|
||||||
|
AND s_temp.child = mc_temp_sub.category_id
|
||||||
|
)
|
||||||
|
) AS MC
|
||||||
ON MC.manga_id = M._id
|
ON MC.manga_id = M._id
|
||||||
WHERE M.favorite = 1;
|
WHERE M.favorite = 1;
|
||||||
|
|
||||||
|
|
|
@ -18,19 +18,32 @@ class CreateCategoryWithName(
|
||||||
return sort.type.flag or sort.direction.flag
|
return sort.type.flag or sort.direction.flag
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun await(name: String): Result = withNonCancellableContext {
|
suspend fun await(name: String, isSubcategory: Boolean = false): Result = withNonCancellableContext {
|
||||||
val categories = categoryRepository.getAll()
|
// subcategories should not interfere with the sort order of parent categories
|
||||||
val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
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(
|
val newCategory = Category(
|
||||||
id = 0,
|
id = 0,
|
||||||
name = name,
|
name = name,
|
||||||
order = nextOrder,
|
order = nextOrder,
|
||||||
flags = initialFlags,
|
flags = initialFlags,
|
||||||
|
parent = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
categoryRepository.insert(newCategory)
|
categoryRepository.insert(newCategory)
|
||||||
Result.Success
|
Result.Success(categoryRepository.lastInsertedId())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
Result.InternalError(e)
|
Result.InternalError(e)
|
||||||
|
@ -38,7 +51,7 @@ class CreateCategoryWithName(
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Result {
|
sealed interface Result {
|
||||||
data object Success : Result
|
data class Success(val id: Long) : Result
|
||||||
data class InternalError(val error: Throwable) : Result
|
data class InternalError(val error: Throwable) : Result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 name: String,
|
||||||
val order: Long,
|
val order: Long,
|
||||||
val flags: Long,
|
val flags: Long,
|
||||||
|
val parent: Long,
|
||||||
) : Serializable {
|
) : Serializable {
|
||||||
|
|
||||||
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
|
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
|
||||||
|
|
||||||
|
val isSubcategory = parent > -1
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val UNCATEGORIZED_ID = 0L
|
const val UNCATEGORIZED_ID = 0L
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,4 +25,12 @@ interface CategoryRepository {
|
||||||
suspend fun updateAllFlags(flags: Long?)
|
suspend fun updateAllFlags(flags: Long?)
|
||||||
|
|
||||||
suspend fun delete(categoryId: Long)
|
suspend fun delete(categoryId: Long)
|
||||||
|
|
||||||
|
suspend fun getMaxSubcategoryOrder(): Long
|
||||||
|
|
||||||
|
suspend fun lastInsertedId(): Long
|
||||||
|
|
||||||
|
suspend fun insertSubcategory(parent: Long, child: Long)
|
||||||
|
|
||||||
|
suspend fun deleteSubcategories(categories: List<Long>)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import tachiyomi.domain.manga.model.Manga
|
||||||
data class LibraryManga(
|
data class LibraryManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val category: Long,
|
val category: Long,
|
||||||
|
val parentCategory: Long,
|
||||||
val totalChapters: Long,
|
val totalChapters: Long,
|
||||||
val readCount: Long,
|
val readCount: Long,
|
||||||
val bookmarkCount: Long,
|
val bookmarkCount: Long,
|
||||||
|
|
|
@ -105,6 +105,9 @@
|
||||||
<string name="action_open_in_browser">Open in browser</string>
|
<string name="action_open_in_browser">Open in browser</string>
|
||||||
<string name="action_show_manga">Show entry</string>
|
<string name="action_show_manga">Show entry</string>
|
||||||
<string name="action_copy_to_clipboard">Copy to clipboard</string>
|
<string name="action_copy_to_clipboard">Copy to clipboard</string>
|
||||||
|
<string name="action_create_subcategory">Create subcategory</string>
|
||||||
|
<string name="action_add_subcategory">Add subcategory</string>
|
||||||
|
|
||||||
<!-- Do not translate "WebView" -->
|
<!-- Do not translate "WebView" -->
|
||||||
<string name="action_open_in_web_view">Open in WebView</string>
|
<string name="action_open_in_web_view">Open in WebView</string>
|
||||||
<string name="action_web_view" translatable="false">WebView</string>
|
<string name="action_web_view" translatable="false">WebView</string>
|
||||||
|
@ -748,6 +751,7 @@
|
||||||
|
|
||||||
<!-- Category activity -->
|
<!-- Category activity -->
|
||||||
<string name="error_category_exists">A category with this name already exists!</string>
|
<string name="error_category_exists">A category with this name already exists!</string>
|
||||||
|
<string name="error_subcategory_exists">A subcategory with this name already exists!</string>
|
||||||
<!-- missing undo feature after Compose rewrite #7454 -->
|
<!-- missing undo feature after Compose rewrite #7454 -->
|
||||||
<string name="snack_categories_deleted">Categories deleted</string>
|
<string name="snack_categories_deleted">Categories deleted</string>
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ fun TabIndicator(
|
||||||
@Composable
|
@Composable
|
||||||
fun TabText(
|
fun TabText(
|
||||||
text: String,
|
text: String,
|
||||||
badgeCount: Int? = null,
|
badgeText: String? = null,
|
||||||
) {
|
) {
|
||||||
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
||||||
|
|
||||||
|
@ -69,9 +69,9 @@ fun TabText(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(text = text)
|
Text(text = text)
|
||||||
if (badgeCount != null) {
|
if (!badgeText.isNullOrEmpty()) {
|
||||||
Pill(
|
Pill(
|
||||||
text = "$badgeCount",
|
text = badgeText,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|
Reference in a new issue