Use Voyager on Library tab (#8620)

This commit is contained in:
Ivan Iskandar 2022-11-27 03:48:57 +07:00 committed by GitHub
parent fe579c4865
commit e14909fff4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 802 additions and 845 deletions

View file

@ -49,6 +49,9 @@ object CommonMangaItemDefaults {
} }
private val ContinueReadingButtonSize = 32.dp private val ContinueReadingButtonSize = 32.dp
private val ContinueReadingButtonGridPadding = 6.dp
private val ContinueReadingButtonListSpacing = 8.dp
private const val GridSelectedCoverAlpha = 0.76f private const val GridSelectedCoverAlpha = 0.76f
/** /**
@ -61,9 +64,8 @@ fun MangaCompactGridItem(
title: String? = null, title: String? = null,
coverData: eu.kanade.domain.manga.model.MangaCover, coverData: eu.kanade.domain.manga.model.MangaCover,
coverAlpha: Float = 1f, coverAlpha: Float = 1f,
coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeStart: @Composable (RowScope.() -> Unit)? = null,
coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null,
showContinueReadingButton: Boolean = false,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null, onClickContinueReading: (() -> Unit)? = null,
@ -86,12 +88,17 @@ fun MangaCompactGridItem(
badgesEnd = coverBadgeEnd, badgesEnd = coverBadgeEnd,
content = { content = {
if (title != null) { if (title != null) {
CoverTextOverlay(title = title, showContinueReadingButton) CoverTextOverlay(
} title = title,
}, onClickContinueReading = onClickContinueReading,
continueReadingButton = { )
if (showContinueReadingButton && onClickContinueReading != null) { } else if (onClickContinueReading != null) {
ContinueReadingButton(onClickContinueReading) ContinueReadingButton(
modifier = Modifier
.padding(ContinueReadingButtonGridPadding)
.align(Alignment.BottomEnd),
onClickContinueReading = onClickContinueReading,
)
} }
}, },
) )
@ -104,7 +111,7 @@ fun MangaCompactGridItem(
@Composable @Composable
private fun BoxScope.CoverTextOverlay( private fun BoxScope.CoverTextOverlay(
title: String, title: String,
showContinueReadingButton: Boolean = false, onClickContinueReading: (() -> Unit)? = null,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -119,20 +126,33 @@ private fun BoxScope.CoverTextOverlay(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) )
val endPadding = if (showContinueReadingButton) ContinueReadingButtonSize else 0.dp Row(
GridItemTitle( modifier = Modifier.align(Alignment.BottomStart),
modifier = Modifier verticalAlignment = Alignment.Bottom,
.padding(start = 8.dp, top = 8.dp, end = endPadding + 8.dp, bottom = 8.dp) ) {
.align(Alignment.BottomStart), GridItemTitle(
title = title, modifier = Modifier
style = MaterialTheme.typography.titleSmall.copy( .weight(1f)
color = Color.White, .padding(8.dp),
shadow = Shadow( title = title,
color = Color.Black, style = MaterialTheme.typography.titleSmall.copy(
blurRadius = 4f, color = Color.White,
shadow = Shadow(
color = Color.Black,
blurRadius = 4f,
),
), ),
), )
) if (onClickContinueReading != null) {
ContinueReadingButton(
modifier = Modifier.padding(
end = ContinueReadingButtonGridPadding,
bottom = ContinueReadingButtonGridPadding,
),
onClickContinueReading = onClickContinueReading,
)
}
}
} }
/** /**
@ -146,7 +166,6 @@ fun MangaComfortableGridItem(
coverAlpha: Float = 1f, coverAlpha: Float = 1f,
coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
showContinueReadingButton: Boolean = false,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null, onClickContinueReading: (() -> Unit)? = null,
@ -168,9 +187,14 @@ fun MangaComfortableGridItem(
}, },
badgesStart = coverBadgeStart, badgesStart = coverBadgeStart,
badgesEnd = coverBadgeEnd, badgesEnd = coverBadgeEnd,
continueReadingButton = { content = {
if (showContinueReadingButton && onClickContinueReading != null) { if (onClickContinueReading != null) {
ContinueReadingButton(onClickContinueReading) ContinueReadingButton(
modifier = Modifier
.padding(ContinueReadingButtonGridPadding)
.align(Alignment.BottomEnd),
onClickContinueReading = onClickContinueReading,
)
} }
}, },
) )
@ -192,7 +216,6 @@ private fun MangaGridCover(
cover: @Composable BoxScope.() -> Unit = {}, cover: @Composable BoxScope.() -> Unit = {},
badgesStart: (@Composable RowScope.() -> Unit)? = null, badgesStart: (@Composable RowScope.() -> Unit)? = null,
badgesEnd: (@Composable RowScope.() -> Unit)? = null, badgesEnd: (@Composable RowScope.() -> Unit)? = null,
continueReadingButton: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable (BoxScope.() -> Unit)? = null, content: @Composable (BoxScope.() -> Unit)? = null,
) { ) {
Box( Box(
@ -219,7 +242,6 @@ private fun MangaGridCover(
content = badgesEnd, content = badgesEnd,
) )
} }
continueReadingButton?.invoke(this)
} }
} }
@ -310,8 +332,7 @@ fun MangaListItem(
title: String, title: String,
coverData: eu.kanade.domain.manga.model.MangaCover, coverData: eu.kanade.domain.manga.model.MangaCover,
coverAlpha: Float = 1f, coverAlpha: Float = 1f,
badge: @Composable RowScope.() -> Unit, badge: @Composable (RowScope.() -> Unit),
showContinueReadingButton: Boolean = false,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null, onClickContinueReading: (() -> Unit)? = null,
@ -343,23 +364,21 @@ fun MangaListItem(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
BadgeGroup(content = badge) BadgeGroup(content = badge)
if (showContinueReadingButton && onClickContinueReading != null) { if (onClickContinueReading != null) {
Box { ContinueReadingButton(
ContinueReadingButton(onClickContinueReading) modifier = Modifier.padding(start = ContinueReadingButtonListSpacing),
} onClickContinueReading = onClickContinueReading,
)
} }
} }
} }
@Composable @Composable
private fun BoxScope.ContinueReadingButton( private fun ContinueReadingButton(
modifier: Modifier = Modifier,
onClickContinueReading: () -> Unit, onClickContinueReading: () -> Unit,
) { ) {
Box( Box(modifier = modifier) {
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
FilledIconButton( FilledIconButton(
onClick = onClickContinueReading, onClick = onClickContinueReading,
modifier = Modifier.size(ContinueReadingButtonSize), modifier = Modifier.size(ContinueReadingButtonSize),

View file

@ -1,130 +0,0 @@
package eu.kanade.presentation.library
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.util.fastAll
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.library.model.LibraryManga
import eu.kanade.domain.library.model.display
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.EmptyScreenAction
import eu.kanade.presentation.components.LibraryBottomActionMenu
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.library.components.LibraryContent
import eu.kanade.presentation.library.components.LibraryToolbar
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.library.LibraryPresenter
import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
@Composable
fun LibraryScreen(
presenter: LibraryPresenter,
onMangaClicked: (Long) -> Unit,
onContinueReadingClicked: (LibraryManga) -> Unit,
onGlobalSearchClicked: () -> Unit,
onChangeCategoryClicked: () -> Unit,
onMarkAsReadClicked: () -> Unit,
onMarkAsUnreadClicked: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit,
onDeleteClicked: () -> Unit,
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
onClickFilter: () -> Unit,
onClickRefresh: (Category?) -> Boolean,
onClickOpenRandomManga: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
Scaffold(
topBar = { scrollBehavior ->
val title by presenter.getToolbarTitle()
val tabVisible = presenter.tabVisibility && presenter.categories.size > 1
LibraryToolbar(
state = presenter,
title = title,
incognitoMode = !tabVisible && presenter.isIncognitoMode,
downloadedOnlyMode = !tabVisible && presenter.isDownloadOnly,
onClickUnselectAll = onClickUnselectAll,
onClickSelectAll = onClickSelectAll,
onClickInvertSelection = onClickInvertSelection,
onClickFilter = onClickFilter,
onClickRefresh = { onClickRefresh(null) },
onClickOpenRandomManga = onClickOpenRandomManga,
scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab
)
},
bottomBar = {
LibraryBottomActionMenu(
visible = presenter.selectionMode,
onChangeCategoryClicked = onChangeCategoryClicked,
onMarkAsReadClicked = onMarkAsReadClicked,
onMarkAsUnreadClicked = onMarkAsUnreadClicked,
onDownloadClicked = onDownloadClicked.takeIf { presenter.selection.fastAll { !it.manga.isLocal() } },
onDeleteClicked = onDeleteClicked,
)
},
) { paddingValues ->
if (presenter.isLoading) {
LoadingScreen()
return@Scaffold
}
val contentPadding = TachiyomiBottomNavigationView.withBottomNavPadding(paddingValues)
if (presenter.searchQuery.isNullOrEmpty() && presenter.isLibraryEmpty) {
val handler = LocalUriHandler.current
EmptyScreen(
textResource = R.string.information_empty_library,
modifier = Modifier.padding(contentPadding),
actions = listOf(
EmptyScreenAction(
stringResId = R.string.getting_started_guide,
icon = Icons.Outlined.HelpOutline,
onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") },
),
),
)
return@Scaffold
}
LibraryContent(
state = presenter,
contentPadding = contentPadding,
currentPage = { presenter.activeCategory },
isLibraryEmpty = presenter.isLibraryEmpty,
showPageTabs = presenter.tabVisibility,
showMangaCount = presenter.mangaCountVisibility,
onChangeCurrentPage = { presenter.activeCategory = it },
onMangaClicked = onMangaClicked,
onContinueReadingClicked = onContinueReadingClicked,
onToggleSelection = { presenter.toggleSelection(it) },
onToggleRangeSelection = {
presenter.toggleRangeSelection(it)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onRefresh = onClickRefresh,
onGlobalSearchClicked = onGlobalSearchClicked,
getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) },
getDisplayModeForPage = { presenter.categories[it].display },
getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) },
getLibraryForPage = { presenter.getMangaForCategory(page = it) },
showDownloadBadges = presenter.showDownloadBadges,
showUnreadBadges = presenter.showUnreadBadges,
showLocalBadges = presenter.showLocalBadges,
showLanguageBadges = presenter.showLanguageBadges,
showContinueReadingButton = presenter.showContinueReadingButton,
isIncognitoMode = presenter.isIncognitoMode,
isDownloadOnly = presenter.isDownloadOnly,
)
}
}

View file

@ -1,35 +0,0 @@
package eu.kanade.presentation.library
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.library.model.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryPresenter
@Stable
interface LibraryState {
val isLoading: Boolean
val categories: List<Category>
var searchQuery: String?
val selection: List<LibraryManga>
val selectionMode: Boolean
var hasActiveFilters: Boolean
var dialog: LibraryPresenter.Dialog?
}
fun LibraryState(): LibraryState {
return LibraryStateImpl()
}
class LibraryStateImpl : LibraryState {
override var isLoading: Boolean by mutableStateOf(true)
override var categories: List<Category> by mutableStateOf(emptyList())
override var searchQuery: String? by mutableStateOf(null)
override var selection: List<LibraryManga> by mutableStateOf(emptyList())
override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() }
override var hasActiveFilters: Boolean by mutableStateOf(false)
override var dialog: LibraryPresenter.Dialog? by mutableStateOf(null)
}

View file

@ -5,16 +5,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.Badge
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable @Composable
fun DownloadsBadge( fun DownloadsBadge(count: Int) {
enabled: Boolean, if (count > 0) {
item: LibraryItem,
) {
if (enabled && item.downloadCount > 0) {
Badge( Badge(
text = "${item.downloadCount}", text = "$count",
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary, textColor = MaterialTheme.colorScheme.onTertiary,
) )
@ -22,30 +18,26 @@ fun DownloadsBadge(
} }
@Composable @Composable
fun UnreadBadge( fun UnreadBadge(count: Int) {
enabled: Boolean, if (count > 0) {
item: LibraryItem, Badge(text = "$count")
) {
if (enabled && item.unreadCount > 0) {
Badge(text = "${item.unreadCount}")
} }
} }
@Composable @Composable
fun LanguageBadge( fun LanguageBadge(
showLanguage: Boolean, isLocal: Boolean,
showLocal: Boolean, sourceLanguage: String,
item: LibraryItem,
) { ) {
if (showLocal && item.isLocal) { if (isLocal) {
Badge( Badge(
text = stringResource(R.string.local_source_badge), text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary, textColor = MaterialTheme.colorScheme.onTertiary,
) )
} else if (showLanguage && item.sourceLanguage.isNotEmpty()) { } else if (sourceLanguage.isNotEmpty()) {
Badge( Badge(
text = item.sourceLanguage.uppercase(), text = sourceLanguage.uppercase(),
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary, textColor = MaterialTheme.colorScheme.onTertiary,
) )

View file

@ -14,17 +14,12 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable @Composable
fun LibraryComfortableGrid( fun LibraryComfortableGrid(
items: List<LibraryItem>, items: List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
) { ) {
@ -51,26 +46,22 @@ fun LibraryComfortableGrid(
lastModified = manga.coverLastModified, lastModified = manga.coverLastModified,
), ),
coverBadgeStart = { coverBadgeStart = {
DownloadsBadge( DownloadsBadge(count = libraryItem.downloadCount.toInt())
enabled = showDownloadBadges, UnreadBadge(count = libraryItem.unreadCount.toInt())
item = libraryItem,
)
UnreadBadge(
enabled = showUnreadBadges,
item = libraryItem,
)
}, },
coverBadgeEnd = { coverBadgeEnd = {
LanguageBadge( LanguageBadge(
showLanguage = showLanguageBadges, isLocal = libraryItem.isLocal,
showLocal = showLocalBadges, sourceLanguage = libraryItem.sourceLanguage,
item = libraryItem,
) )
}, },
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(libraryItem.libraryManga) }, onLongClick = { onLongClick(libraryItem.libraryManga) },
onClick = { onClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) },
onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, onClickContinueReading = if (onClickContinueReading != null) {
{ onClickContinueReading(libraryItem.libraryManga) }
} else {
null
},
) )
} }
} }

View file

@ -15,17 +15,12 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
fun LibraryCompactGrid( fun LibraryCompactGrid(
items: List<LibraryItem>, items: List<LibraryItem>,
showTitle: Boolean, showTitle: Boolean,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
) { ) {
@ -52,26 +47,22 @@ fun LibraryCompactGrid(
lastModified = manga.coverLastModified, lastModified = manga.coverLastModified,
), ),
coverBadgeStart = { coverBadgeStart = {
DownloadsBadge( DownloadsBadge(count = libraryItem.downloadCount.toInt())
enabled = showDownloadBadges, UnreadBadge(count = libraryItem.unreadCount.toInt())
item = libraryItem,
)
UnreadBadge(
enabled = showUnreadBadges,
item = libraryItem,
)
}, },
coverBadgeEnd = { coverBadgeEnd = {
LanguageBadge( LanguageBadge(
showLanguage = showLanguageBadges, isLocal = libraryItem.isLocal,
showLocal = showLocalBadges, sourceLanguage = libraryItem.sourceLanguage,
item = libraryItem,
) )
}, },
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(libraryItem.libraryManga) }, onLongClick = { onLongClick(libraryItem.libraryManga) },
onClick = { onClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) },
onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, onClickContinueReading = if (onClickContinueReading != null) {
{ onClickContinueReading(libraryItem.libraryManga) }
} else {
null
},
) )
} }
} }

View file

@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -21,7 +20,6 @@ import eu.kanade.domain.library.model.LibraryDisplayMode
import eu.kanade.domain.library.model.LibraryManga import eu.kanade.domain.library.model.LibraryManga
import eu.kanade.presentation.components.SwipeRefresh import eu.kanade.presentation.components.SwipeRefresh
import eu.kanade.presentation.components.rememberPagerState import eu.kanade.presentation.components.rememberPagerState
import eu.kanade.presentation.library.LibraryState
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -29,28 +27,24 @@ import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun LibraryContent( fun LibraryContent(
state: LibraryState, categories: List<Category>,
searchQuery: String?,
selection: List<LibraryManga>,
contentPadding: PaddingValues, contentPadding: PaddingValues,
currentPage: () -> Int, currentPage: () -> Int,
isLibraryEmpty: Boolean, isLibraryEmpty: Boolean,
showPageTabs: Boolean, showPageTabs: Boolean,
showMangaCount: Boolean,
onChangeCurrentPage: (Int) -> Unit, onChangeCurrentPage: (Int) -> Unit,
onMangaClicked: (Long) -> Unit, onMangaClicked: (Long) -> 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: @Composable (Long) -> State<Int?>, getNumberOfMangaForCategory: (Category) -> Int?,
getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> List<LibraryItem>, getLibraryForPage: (Int) -> List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
isDownloadOnly: Boolean, isDownloadOnly: Boolean,
isIncognitoMode: Boolean, isIncognitoMode: Boolean,
) { ) {
@ -61,38 +55,30 @@ fun LibraryContent(
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
), ),
) { ) {
val categories = state.categories
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
val pagerState = rememberPagerState(coercedCurrentPage) val pagerState = rememberPagerState(coercedCurrentPage)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (isLibraryEmpty.not() && showPageTabs && categories.size > 1) { if (!isLibraryEmpty && showPageTabs && categories.size > 1) {
LibraryTabs( LibraryTabs(
categories = categories, categories = categories,
currentPageIndex = pagerState.currentPage, currentPageIndex = pagerState.currentPage,
showMangaCount = showMangaCount,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
isDownloadOnly = isDownloadOnly, isDownloadOnly = isDownloadOnly,
isIncognitoMode = isIncognitoMode, isIncognitoMode = isIncognitoMode,
onTabItemClick = { scope.launch { pagerState.animateScrollToPage(it) } }, getNumberOfMangaForCategory = getNumberOfMangaForCategory,
) ) { scope.launch { pagerState.animateScrollToPage(it) } }
} }
val notSelectionMode = selection.isEmpty()
val onClickManga = { manga: LibraryManga -> val onClickManga = { manga: LibraryManga ->
if (state.selectionMode.not()) { if (notSelectionMode) {
onMangaClicked(manga.manga.id) onMangaClicked(manga.manga.id)
} else { } else {
onToggleSelection(manga) onToggleSelection(manga)
} }
} }
val onLongClickManga = { manga: LibraryManga ->
onToggleRangeSelection(manga)
}
val onClickContinueReading = { manga: LibraryManga ->
onContinueReadingClicked(manga)
}
SwipeRefresh( SwipeRefresh(
refreshing = isRefreshing, refreshing = isRefreshing,
@ -106,26 +92,21 @@ fun LibraryContent(
isRefreshing = false isRefreshing = false
} }
}, },
enabled = state.selectionMode.not(), enabled = notSelectionMode,
) { ) {
LibraryPager( LibraryPager(
state = pagerState, state = pagerState,
contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()),
pageCount = categories.size, pageCount = categories.size,
selectedManga = state.selection, selectedManga = selection,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
getDisplayModeForPage = getDisplayModeForPage, getDisplayModeForPage = getDisplayModeForPage,
getColumnsForOrientation = getColumnsForOrientation, getColumnsForOrientation = getColumnsForOrientation,
getLibraryForPage = getLibraryForPage, getLibraryForPage = getLibraryForPage,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
onClickManga = onClickManga, onClickManga = onClickManga,
onLongClickManga = onLongClickManga, onLongClickManga = onToggleRangeSelection,
onClickContinueReading = onClickContinueReading, onClickContinueReading = onContinueReadingClicked,
onGlobalSearchClicked = onGlobalSearchClicked,
searchQuery = state.searchQuery,
) )
} }

View file

@ -23,16 +23,11 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable @Composable
fun LibraryList( fun LibraryList(
items: List<LibraryItem>, items: List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
) { ) {
@ -41,13 +36,13 @@ fun LibraryList(
contentPadding = contentPadding + PaddingValues(vertical = 8.dp), contentPadding = contentPadding + PaddingValues(vertical = 8.dp),
) { ) {
item { item {
if (searchQuery.isNullOrEmpty().not()) { if (!searchQuery.isNullOrEmpty()) {
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = onGlobalSearchClicked, onClick = onGlobalSearchClicked,
) { ) {
Text( Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!), text = stringResource(R.string.action_global_search_query, searchQuery),
modifier = Modifier.zIndex(99f), modifier = Modifier.zIndex(99f),
) )
} }
@ -70,14 +65,20 @@ fun LibraryList(
lastModified = manga.coverLastModified, lastModified = manga.coverLastModified,
), ),
badge = { badge = {
DownloadsBadge(enabled = showDownloadBadges, item = libraryItem) DownloadsBadge(count = libraryItem.downloadCount.toInt())
UnreadBadge(enabled = showUnreadBadges, item = libraryItem) UnreadBadge(count = libraryItem.unreadCount.toInt())
LanguageBadge(showLanguage = showLanguageBadges, showLocal = showLocalBadges, item = libraryItem) LanguageBadge(
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,
)
}, },
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(libraryItem.libraryManga) }, onLongClick = { onLongClick(libraryItem.libraryManga) },
onClick = { onClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) },
onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, onClickContinueReading = if (onClickContinueReading != null) {
{ onClickContinueReading(libraryItem.libraryManga) }
} else {
null
},
) )
} }
} }

View file

@ -27,15 +27,10 @@ fun LibraryPager(
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> List<LibraryItem>, getLibraryForPage: (Int) -> List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
onClickManga: (LibraryManga) -> Unit, onClickManga: (LibraryManga) -> Unit,
onLongClickManga: (LibraryManga) -> Unit, onLongClickManga: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?,
) { ) {
HorizontalPager( HorizontalPager(
count = pageCount, count = pageCount,
@ -62,11 +57,6 @@ fun LibraryPager(
LibraryDisplayMode.List -> { LibraryDisplayMode.List -> {
LibraryList( LibraryList(
items = library, items = library,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selectedManga,
onClick = onClickManga, onClick = onClickManga,
@ -80,11 +70,6 @@ fun LibraryPager(
LibraryCompactGrid( LibraryCompactGrid(
items = library, items = library,
showTitle = displayMode is LibraryDisplayMode.CompactGrid, showTitle = displayMode is LibraryDisplayMode.CompactGrid,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selectedManga,
@ -98,17 +83,12 @@ fun LibraryPager(
LibraryDisplayMode.ComfortableGrid -> { LibraryDisplayMode.ComfortableGrid -> {
LibraryComfortableGrid( LibraryComfortableGrid(
items = library, items = library,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selectedManga,
onClick = onClickManga, onClick = onClickManga,
onClickContinueReading = onClickContinueReading,
onLongClick = onLongClickManga, onLongClick = onLongClickManga,
onClickContinueReading = onClickContinueReading,
searchQuery = searchQuery, searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked, onGlobalSearchClicked = onGlobalSearchClicked,
) )

View file

@ -5,8 +5,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.category.visualName import eu.kanade.presentation.category.visualName
@ -19,10 +17,9 @@ import eu.kanade.presentation.components.TabText
fun LibraryTabs( fun LibraryTabs(
categories: List<Category>, categories: List<Category>,
currentPageIndex: Int, currentPageIndex: Int,
showMangaCount: Boolean,
isDownloadOnly: Boolean, isDownloadOnly: Boolean,
isIncognitoMode: Boolean, isIncognitoMode: Boolean,
getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>, getNumberOfMangaForCategory: (Category) -> Int?,
onTabItemClick: (Int) -> Unit, onTabItemClick: (Int) -> Unit,
) { ) {
Column { Column {
@ -41,11 +38,7 @@ fun LibraryTabs(
text = { text = {
TabText( TabText(
text = category.visualName, text = category.visualName,
badgeCount = if (showMangaCount) { badgeCount = getNumberOfMangaForCategory(category),
getNumberOfMangaForCategory(category.id)
} else {
null
}?.value,
) )
}, },
unselectedContentColor = MaterialTheme.colorScheme.onSurface, unselectedContentColor = MaterialTheme.colorScheme.onSurface,

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -23,13 +24,13 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.OverflowMenu import eu.kanade.presentation.components.OverflowMenu
import eu.kanade.presentation.components.Pill import eu.kanade.presentation.components.Pill
import eu.kanade.presentation.components.SearchToolbar import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.library.LibraryState
import eu.kanade.presentation.theme.active import eu.kanade.presentation.theme.active
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@Composable @Composable
fun LibraryToolbar( fun LibraryToolbar(
state: LibraryState, hasActiveFilters: Boolean,
selectedCount: Int,
title: LibraryToolbarTitle, title: LibraryToolbarTitle,
incognitoMode: Boolean, incognitoMode: Boolean,
downloadedOnlyMode: Boolean, downloadedOnlyMode: Boolean,
@ -39,10 +40,12 @@ fun LibraryToolbar(
onClickFilter: () -> Unit, onClickFilter: () -> Unit,
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
onClickOpenRandomManga: () -> Unit, onClickOpenRandomManga: () -> Unit,
searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
scrollBehavior: TopAppBarScrollBehavior?, scrollBehavior: TopAppBarScrollBehavior?,
) = when { ) = when {
state.selectionMode -> LibrarySelectionToolbar( selectedCount > 0 -> LibrarySelectionToolbar(
state = state, selectedCount = selectedCount,
incognitoMode = incognitoMode, incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode, downloadedOnlyMode = downloadedOnlyMode,
onClickUnselectAll = onClickUnselectAll, onClickUnselectAll = onClickUnselectAll,
@ -51,11 +54,11 @@ fun LibraryToolbar(
) )
else -> LibraryRegularToolbar( else -> LibraryRegularToolbar(
title = title, title = title,
hasFilters = state.hasActiveFilters, hasFilters = hasActiveFilters,
incognitoMode = incognitoMode, incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode, downloadedOnlyMode = downloadedOnlyMode,
searchQuery = state.searchQuery, searchQuery = searchQuery,
onChangeSearchQuery = { state.searchQuery = it }, onSearchQueryChange = onSearchQueryChange,
onClickFilter = onClickFilter, onClickFilter = onClickFilter,
onClickRefresh = onClickRefresh, onClickRefresh = onClickRefresh,
onClickOpenRandomManga = onClickOpenRandomManga, onClickOpenRandomManga = onClickOpenRandomManga,
@ -70,7 +73,7 @@ fun LibraryRegularToolbar(
incognitoMode: Boolean, incognitoMode: Boolean,
downloadedOnlyMode: Boolean, downloadedOnlyMode: Boolean,
searchQuery: String?, searchQuery: String?,
onChangeSearchQuery: (String?) -> Unit, onSearchQueryChange: (String?) -> Unit,
onClickFilter: () -> Unit, onClickFilter: () -> Unit,
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
onClickOpenRandomManga: () -> Unit, onClickOpenRandomManga: () -> Unit,
@ -96,7 +99,7 @@ fun LibraryRegularToolbar(
} }
}, },
searchQuery = searchQuery, searchQuery = searchQuery,
onChangeSearchQuery = onChangeSearchQuery, onChangeSearchQuery = onSearchQueryChange,
actions = { actions = {
val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current
IconButton(onClick = onClickFilter) { IconButton(onClick = onClickFilter) {
@ -128,7 +131,7 @@ fun LibraryRegularToolbar(
@Composable @Composable
fun LibrarySelectionToolbar( fun LibrarySelectionToolbar(
state: LibraryState, selectedCount: Int,
incognitoMode: Boolean, incognitoMode: Boolean,
downloadedOnlyMode: Boolean, downloadedOnlyMode: Boolean,
onClickUnselectAll: () -> Unit, onClickUnselectAll: () -> Unit,
@ -136,7 +139,7 @@ fun LibrarySelectionToolbar(
onClickInvertSelection: () -> Unit, onClickInvertSelection: () -> Unit,
) { ) {
AppBar( AppBar(
titleContent = { Text(text = "${state.selection.size}") }, titleContent = { Text(text = "$selectedCount") },
actions = { actions = {
IconButton(onClick = onClickSelectAll) { IconButton(onClick = onClickSelectAll) {
Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all)) Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all))
@ -152,6 +155,7 @@ fun LibrarySelectionToolbar(
) )
} }
@Immutable
data class LibraryToolbarTitle( data class LibraryToolbarTitle(
val text: String, val text: String,
val numberOfManga: Int? = null, val numberOfManga: Int? = null,

View file

@ -1,167 +1,37 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.View import android.view.View
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import cafe.adriel.voyager.navigator.Navigator
import androidx.compose.ui.platform.LocalContext import eu.kanade.domain.category.model.Category
import com.bluelinelabs.conductor.ControllerChangeHandler import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import com.bluelinelabs.conductor.ControllerChangeType
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.library.model.LibraryManga
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DeleteLibraryMangaDialog
import eu.kanade.presentation.library.LibraryScreen
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class LibraryController( class LibraryController(
bundle: Bundle? = null, bundle: Bundle? = null,
) : FullComposeController<LibraryPresenter>(bundle), RootController { ) : BasicFullComposeController(bundle), RootController {
/** /**
* Sheet containing filter/sort/display items. * Sheet containing filter/sort/display items.
*/ */
private var settingsSheet: LibrarySettingsSheet? = null private var settingsSheet: LibrarySettingsSheet? = null
override fun createPresenter(): LibraryPresenter = LibraryPresenter()
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
val context = LocalContext.current Navigator(screen = LibraryScreen)
val getMangaForCategory = presenter.getMangaForCategory(page = presenter.activeCategory)
LibraryScreen(
presenter = presenter,
onMangaClicked = ::openManga,
onContinueReadingClicked = ::continueReading,
onGlobalSearchClicked = {
router.pushController(GlobalSearchController(presenter.searchQuery))
},
onChangeCategoryClicked = ::showMangaCategoriesDialog,
onMarkAsReadClicked = { markReadStatus(true) },
onMarkAsUnreadClicked = { markReadStatus(false) },
onDownloadClicked = ::runDownloadChapterAction,
onDeleteClicked = ::showDeleteMangaDialog,
onClickFilter = ::showSettingsSheet,
onClickRefresh = {
val started = LibraryUpdateService.start(context, it)
context.toast(if (started) R.string.updating_category else R.string.update_already_running)
started
},
onClickOpenRandomManga = {
val items = getMangaForCategory.map { it.libraryManga.manga.id }
if (getMangaForCategory.isNotEmpty()) {
openManga(items.random())
} else {
context.toast(R.string.information_no_entries_found)
}
},
onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) },
onClickSelectAll = { presenter.selectAll(presenter.activeCategory) },
onClickUnselectAll = ::clearSelection,
)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is LibraryPresenter.Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
presenter.clearSelection()
router.pushController(CategoryController())
},
onConfirm = { include, exclude ->
presenter.clearSelection()
presenter.setMangaCategories(dialog.manga, include, exclude)
},
)
}
is LibraryPresenter.Dialog.DeleteManga -> {
DeleteLibraryMangaDialog(
containsLocalManga = dialog.manga.any(Manga::isLocal),
onDismissRequest = onDismissRequest,
onConfirm = { deleteManga, deleteChapter ->
presenter.removeMangas(dialog.manga, deleteManga, deleteChapter)
presenter.clearSelection()
},
)
}
is LibraryPresenter.Dialog.DownloadCustomAmount -> {
DownloadCustomAmountDialog(
maxAmount = dialog.max,
onDismissRequest = onDismissRequest,
onConfirm = { amount ->
presenter.downloadUnreadChapters(dialog.manga, amount)
presenter.clearSelection()
},
)
}
null -> {}
}
LaunchedEffect(presenter.selectionMode) {
// Could perhaps be removed when navigation is in a Compose world
if (router.backstackSize == 1) {
(activity as? MainActivity)?.showBottomNav(presenter.selectionMode.not())
}
}
LaunchedEffect(presenter.isLoading) {
if (!presenter.isLoading) {
(activity as? MainActivity)?.ready = true
}
}
}
override fun handleBack(): Boolean {
return when {
presenter.selection.isNotEmpty() -> {
presenter.clearSelection()
true
}
presenter.searchQuery != null -> {
presenter.searchQuery = null
true
}
else -> false
}
} }
override fun onViewCreated(view: View) { override fun onViewCreated(view: View) {
super.onViewCreated(view) super.onViewCreated(view)
settingsSheet = LibrarySettingsSheet(router) { group -> settingsSheet = LibrarySettingsSheet(router)
when (group) { viewScope.launch {
is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() LibraryScreen.openSettingsSheetEvent
else -> {} // Handled via different mechanisms .collectLatest(::showSettingsSheet)
}
}
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
presenter.subscribeLibrary()
} }
} }
@ -171,111 +41,13 @@ class LibraryController(
super.onDestroyView(view) super.onDestroyView(view)
} }
fun showSettingsSheet() { fun showSettingsSheet(category: Category? = null) {
presenter.categories.getOrNull(presenter.activeCategory)?.let { category -> if (category != null) {
settingsSheet?.show(category) settingsSheet?.show(category)
} else {
viewScope.launch { LibraryScreen.requestOpenSettingsSheet() }
} }
} }
private fun onFilterChanged() { fun search(query: String) = LibraryScreen.search(query)
viewScope.launchUI {
presenter.requestFilterUpdate()
activity?.invalidateOptionsMenu()
}
}
fun search(query: String) {
presenter.searchQuery = query
}
override fun onPrepareOptionsMenu(menu: Menu) {
val settingsSheet = settingsSheet ?: return
presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters()
}
private fun openManga(mangaId: Long) {
presenter.onOpenManga()
router.pushController(MangaController(mangaId))
}
private fun continueReading(libraryManga: LibraryManga) {
viewScope.launchIO {
val chapter = presenter.getNextUnreadChapter(libraryManga.manga)
if (chapter != null) {
openChapter(chapter)
} else {
withUIContext { activity?.toast(R.string.no_next_chapter) }
}
}
}
private fun openChapter(chapter: Chapter) {
activity?.run {
startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id))
}
}
/**
* Clear all of the manga currently selected, and
* invalidate the action mode to revert the top toolbar
*/
private fun clearSelection() {
presenter.clearSelection()
}
/**
* Move the selected manga to a list of categories.
*/
private fun showMangaCategoriesDialog() {
viewScope.launchIO {
// Create a copy of selected manga
val mangaList = presenter.selection.map { it.manga }
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0L }
// Get indexes of the common categories to preselect.
val common = presenter.getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = presenter.getMixCategories(mangaList)
val preselected = categories.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}
presenter.dialog = LibraryPresenter.Dialog.ChangeCategory(mangaList, preselected)
}
}
private fun runDownloadChapterAction(action: DownloadAction) {
val mangas = presenter.selection.map { it.manga }.toList()
when (action) {
DownloadAction.NEXT_1_CHAPTER -> presenter.downloadUnreadChapters(mangas, 1)
DownloadAction.NEXT_5_CHAPTERS -> presenter.downloadUnreadChapters(mangas, 5)
DownloadAction.NEXT_10_CHAPTERS -> presenter.downloadUnreadChapters(mangas, 10)
DownloadAction.UNREAD_CHAPTERS -> presenter.downloadUnreadChapters(mangas, null)
DownloadAction.CUSTOM -> {
presenter.dialog = LibraryPresenter.Dialog.DownloadCustomAmount(
mangas,
presenter.selection.maxOf { it.unreadCount }.toInt(),
)
return
}
else -> {}
}
presenter.clearSelection()
}
private fun markReadStatus(read: Boolean) {
val mangaList = presenter.selection.toList()
presenter.markReadStatus(mangaList.map { it.manga }, read)
presenter.clearSelection()
}
private fun showDeleteMangaDialog() {
val mangaList = presenter.selection.map { it.manga }
presenter.dialog = LibraryPresenter.Dialog.DeleteManga(mangaList)
}
} }

View file

@ -0,0 +1,270 @@
package eu.kanade.tachiyomi.ui.library
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.util.fastAll
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import com.bluelinelabs.conductor.Router
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.library.model.LibraryManga
import eu.kanade.domain.library.model.display
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DeleteLibraryMangaDialog
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.EmptyScreenAction
import eu.kanade.presentation.components.LibraryBottomActionMenu
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.library.components.LibraryContent
import eu.kanade.presentation.library.components.LibraryToolbar
import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object LibraryScreen : Screen {
@Composable
override fun Content() {
val router = LocalRouter.currentOrThrow
val context = LocalContext.current
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
val screenModel = rememberScreenModel { LibraryScreenModel() }
val state by screenModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val onClickRefresh: (Category?) -> Boolean = {
val started = LibraryUpdateService.start(context, it)
scope.launch {
val msgRes = if (started) R.string.updating_category else R.string.update_already_running
snackbarHostState.showSnackbar(context.getString(msgRes))
}
started
}
val onClickFilter: () -> Unit = {
scope.launch { sendSettingsSheetIntent(state.categories[screenModel.activeCategory]) }
}
Scaffold(
topBar = { scrollBehavior ->
val title = state.getToolbarTitle(
defaultTitle = stringResource(R.string.label_library),
defaultCategoryTitle = stringResource(R.string.label_default),
page = screenModel.activeCategory,
)
val tabVisible = state.showCategoryTabs && state.categories.size > 1
LibraryToolbar(
hasActiveFilters = state.hasActiveFilters,
selectedCount = state.selection.size,
title = title,
incognitoMode = !tabVisible && screenModel.isIncognitoMode,
downloadedOnlyMode = !tabVisible && screenModel.isDownloadOnly,
onClickUnselectAll = screenModel::clearSelection,
onClickSelectAll = { screenModel.selectAll(screenModel.activeCategory) },
onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategory) },
onClickFilter = onClickFilter,
onClickRefresh = { onClickRefresh(null) },
onClickOpenRandomManga = {
scope.launch {
val randomItem = screenModel.getRandomLibraryItemForCurrentCategory()
if (randomItem != null) {
router.openManga(randomItem.libraryManga.manga.id)
} else {
snackbarHostState.showSnackbar(context.getString(R.string.information_no_entries_found))
}
}
},
searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search,
scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab
)
},
bottomBar = {
LibraryBottomActionMenu(
visible = state.selectionMode,
onChangeCategoryClicked = screenModel::openChangeCategoryDialog,
onMarkAsReadClicked = { screenModel.markReadSelection(true) },
onMarkAsUnreadClicked = { screenModel.markReadSelection(false) },
onDownloadClicked = screenModel::runDownloadActionSelection
.takeIf { state.selection.fastAll { !it.manga.isLocal() } },
onDeleteClicked = screenModel::openDeleteMangaDialog,
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets),
) { contentPadding ->
if (state.isLoading) {
LoadingScreen(modifier = Modifier.padding(contentPadding))
return@Scaffold
}
if (state.searchQuery.isNullOrEmpty() && state.library.isEmpty()) {
val handler = LocalUriHandler.current
EmptyScreen(
textResource = R.string.information_empty_library,
modifier = Modifier.padding(contentPadding),
actions = listOf(
EmptyScreenAction(
stringResId = R.string.getting_started_guide,
icon = Icons.Outlined.HelpOutline,
onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") },
),
),
)
return@Scaffold
}
LibraryContent(
categories = state.categories,
searchQuery = state.searchQuery,
selection = state.selection,
contentPadding = contentPadding,
currentPage = { screenModel.activeCategory },
isLibraryEmpty = state.library.isEmpty(),
showPageTabs = state.showCategoryTabs,
onChangeCurrentPage = { screenModel.activeCategory = it },
onMangaClicked = { router.openManga(it) },
onContinueReadingClicked = { it: LibraryManga ->
scope.launchIO {
val chapter = screenModel.getNextUnreadChapter(it.manga)
if (chapter != null) {
context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id))
} else {
snackbarHostState.showSnackbar(context.getString(R.string.no_next_chapter))
}
}
Unit
}.takeIf { state.showMangaContinueButton },
onToggleSelection = { screenModel.toggleSelection(it) },
onToggleRangeSelection = {
screenModel.toggleRangeSelection(it)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onRefresh = onClickRefresh,
onGlobalSearchClicked = {
router.pushController(GlobalSearchController(screenModel.state.value.searchQuery))
},
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
getDisplayModeForPage = { state.categories[it].display },
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
getLibraryForPage = { state.getLibraryItemsByPage(it) },
isDownloadOnly = screenModel.isDownloadOnly,
isIncognitoMode = screenModel.isIncognitoMode,
)
}
val onDismissRequest = screenModel::closeDialog
when (val dialog = state.dialog) {
is LibraryScreenModel.Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
screenModel.clearSelection()
router.pushController(CategoryController())
},
onConfirm = { include, exclude ->
screenModel.clearSelection()
screenModel.setMangaCategories(dialog.manga, include, exclude)
},
)
}
is LibraryScreenModel.Dialog.DeleteManga -> {
DeleteLibraryMangaDialog(
containsLocalManga = dialog.manga.any(Manga::isLocal),
onDismissRequest = onDismissRequest,
onConfirm = { deleteManga, deleteChapter ->
screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter)
screenModel.clearSelection()
},
)
}
is LibraryScreenModel.Dialog.DownloadCustomAmount -> {
DownloadCustomAmountDialog(
maxAmount = dialog.max,
onDismissRequest = onDismissRequest,
onConfirm = { amount ->
screenModel.downloadUnreadChapters(dialog.manga, amount)
screenModel.clearSelection()
},
)
}
null -> {}
}
BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
when {
state.selectionMode -> screenModel.clearSelection()
state.searchQuery != null -> screenModel.search(null)
}
}
LaunchedEffect(state.selectionMode) {
// Could perhaps be removed when navigation is in a Compose world
if (router.backstackSize == 1) {
(context as? MainActivity)?.showBottomNav(!state.selectionMode)
}
}
LaunchedEffect(state.isLoading) {
if (!state.isLoading) {
(context as? MainActivity)?.ready = true
}
}
LaunchedEffect(Unit) {
launch { queryEvent.collectLatest(screenModel::search) }
launch { requestSettingsSheetEvent.collectLatest { onClickFilter() } }
}
}
private fun Router.openManga(mangaId: Long) {
pushController(MangaController(mangaId))
}
// For invoking search from other screen
private val queryEvent = MutableSharedFlow<String>(replay = 1)
fun search(query: String) = queryEvent.tryEmit(query)
// For opening settings sheet in LibraryController
private val requestSettingsSheetEvent = MutableSharedFlow<Unit>()
private val openSettingsSheetEvent_ = MutableSharedFlow<Category>()
val openSettingsSheetEvent = openSettingsSheetEvent_.asSharedFlow()
private suspend fun sendSettingsSheetIntent(category: Category) = openSettingsSheetEvent_.emit(category)
suspend fun requestOpenSettingsSheet() = requestSettingsSheetEvent.emit(Unit)
}

View file

@ -1,18 +1,15 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.os.Bundle import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMap
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.core.prefs.CheckboxState import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.PreferenceMutableState import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.core.prefs.asState
import eu.kanade.core.util.fastFilter import eu.kanade.core.util.fastFilter
import eu.kanade.core.util.fastFilterNot import eu.kanade.core.util.fastFilterNot
import eu.kanade.core.util.fastMapNotNull import eu.kanade.core.util.fastMapNotNull
@ -35,11 +32,8 @@ import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.track.interactor.GetTracksPerManga import eu.kanade.domain.track.interactor.GetTracksPerManga
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.library.LibraryState
import eu.kanade.presentation.library.LibraryStateImpl
import eu.kanade.presentation.library.components.LibraryToolbarTitle import eu.kanade.presentation.library.components.LibraryToolbarTitle
import eu.kanade.tachiyomi.R import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@ -47,38 +41,33 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.Collator import java.text.Collator
import java.util.Collections import java.util.Collections
import java.util.Locale import java.util.Locale
/**
* Class containing library information.
*/
private data class Library(val categories: List<Category>, val mangaMap: LibraryMap)
/** /**
* Typealias for the library manga, using the category as keys, and list of manga as values. * Typealias for the library manga, using the category as keys, and list of manga as values.
*/ */
typealias LibraryMap = Map<Long, List<LibraryItem>> typealias LibraryMap = Map<Category, List<LibraryItem>>
class LibraryPresenter( class LibraryScreenModel(
private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getLibraryManga: GetLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(), private val getCategories: GetCategories = Injekt.get(),
private val getTracksPerManga: GetTracksPerManga = Injekt.get(), private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
@ -94,90 +83,114 @@ class LibraryPresenter(
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
) : BasePresenter<LibraryController>(), LibraryState by state { ) : StateScreenModel<LibraryScreenModel.State>(State()) {
private var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>()) // This is active category INDEX NUMBER
var activeCategory: Int by libraryPreferences.lastUsedCategory().asState(coroutineScope)
val isLibraryEmpty by derivedStateOf { loadedManga.isEmpty() } val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
val tabVisibility by libraryPreferences.categoryTabs().asState() init {
val mangaCountVisibility by libraryPreferences.categoryNumberOfItems().asState() coroutineScope.launchIO {
combine(
val showDownloadBadges by libraryPreferences.downloadBadge().asState() state.map { it.searchQuery }.distinctUntilChanged(),
val showUnreadBadges by libraryPreferences.unreadBadge().asState() getLibraryFlow(),
val showLocalBadges by libraryPreferences.localBadge().asState() getTracksPerManga.subscribe(),
val showLanguageBadges by libraryPreferences.languageBadge().asState() getTrackingFilterFlow(),
) { searchQuery, library, tracks, loggedInTrackServices ->
var activeCategory: Int by libraryPreferences.lastUsedCategory().asState() library
.applyFilters(tracks, loggedInTrackServices)
val showContinueReadingButton by libraryPreferences.showContinueReadingButton().asState() .applySort()
.mapValues { (_, value) ->
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() if (searchQuery != null) {
val isIncognitoMode: Boolean by preferences.incognitoMode().asState() // Filter query
value.filter { it.matches(searchQuery) }
private val _filterChanges: Channel<Unit> = Channel(Int.MAX_VALUE) } else {
private val filterChanges = _filterChanges.receiveAsFlow().onStart { emit(Unit) } // Don't do anything
value
private var librarySubscription: Job? = null }
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()
}
fun subscribeLibrary() {
/**
* TODO:
* - Move filter and sort to getMangaForCategory and only filter and sort the current display category instead of whole library as some has 5000+ items in the library
* - Create new db view and new query to just fetch the current category save as needed to instance variable
* - Fetch badges to maps and retrieve as needed instead of fetching all of them at once
*/
if (librarySubscription == null || librarySubscription!!.isCancelled) {
librarySubscription = presenterScope.launchIO {
combine(getLibraryFlow(), getTracksPerManga.subscribe(), filterChanges) { library, tracks, _ ->
library.mangaMap
.applyFilters(tracks)
.applySort(library.categories)
}
.collectLatest {
state.isLoading = false
loadedManga = it
} }
} }
.collectLatest {
mutableState.update { state ->
state.copy(
isLoading = false,
library = it,
)
}
}
} }
combine(
libraryPreferences.categoryTabs().changes(),
libraryPreferences.categoryNumberOfItems().changes(),
libraryPreferences.showContinueReadingButton().changes(),
) { a, b, c -> arrayOf(a, b, c) }
.onEach { (showCategoryTabs, showMangaCount, showMangaContinueButton) ->
mutableState.update { state ->
state.copy(
showCategoryTabs = showCategoryTabs,
showMangaCount = showMangaCount,
showMangaContinueButton = showMangaContinueButton,
)
}
}
.launchIn(coroutineScope)
combine(
getLibraryItemPreferencesFlow(),
getTrackingFilterFlow(),
) { prefs, trackFilter ->
val a = (
prefs.filterDownloaded or
prefs.filterUnread or
prefs.filterStarted or
prefs.filterBookmarked or
prefs.filterCompleted
) != TriStateGroup.State.IGNORE.value
val b = trackFilter.values.any { it != TriStateGroup.State.IGNORE.value }
a || b
}
.distinctUntilChanged()
.onEach {
mutableState.update { state ->
state.copy(hasActiveFilters = it)
}
}
.launchIn(coroutineScope)
} }
/** /**
* Applies library filters to the given map of manga. * Applies library filters to the given map of manga.
*/ */
private fun LibraryMap.applyFilters(trackMap: Map<Long, List<Long>>): LibraryMap { private suspend fun LibraryMap.applyFilters(
val downloadedOnly = preferences.downloadedOnly().get() trackMap: Map<Long, List<Long>>,
val filterDownloaded = libraryPreferences.filterDownloaded().get() loggedInTrackServices: Map<Long, Int>,
val filterUnread = libraryPreferences.filterUnread().get() ): LibraryMap {
val filterStarted = libraryPreferences.filterStarted().get() val prefs = getLibraryItemPreferencesFlow().first()
val filterBookmarked = libraryPreferences.filterBookmarked().get() val downloadedOnly = prefs.globalFilterDownloaded
val filterCompleted = libraryPreferences.filterCompleted().get() val filterDownloaded = prefs.filterDownloaded
val filterUnread = prefs.filterUnread
val filterStarted = prefs.filterStarted
val filterBookmarked = prefs.filterBookmarked
val filterCompleted = prefs.filterCompleted
val loggedInTrackServices = trackManager.services.fastFilter { trackService -> trackService.isLogged }
.associate { trackService ->
trackService.id to libraryPreferences.filterTracking(trackService.id.toInt()).get()
}
val isNotLoggedInAnyTrack = loggedInTrackServices.isEmpty() val isNotLoggedInAnyTrack = loggedInTrackServices.isEmpty()
val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.EXCLUDE.value) it.key else null } val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.EXCLUDE.value) it.key else null }
val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.INCLUDE.value) it.key else null } val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.INCLUDE.value) it.key else null }
val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty() val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty()
val filterFnDownloaded: (LibraryItem) -> Boolean = downloaded@{ item -> val filterFnDownloaded: (LibraryItem) -> Boolean = downloaded@{ item ->
if (!downloadedOnly && filterDownloaded == State.IGNORE.value) return@downloaded true if (!downloadedOnly && filterDownloaded == TriStateGroup.State.IGNORE.value) return@downloaded true
val isDownloaded = when { val isDownloaded = when {
item.libraryManga.manga.isLocal() -> true item.libraryManga.manga.isLocal() -> true
item.downloadCount != -1L -> item.downloadCount > 0 item.downloadCount != -1L -> item.downloadCount > 0
else -> downloadManager.getDownloadCount(item.libraryManga.manga) > 0 else -> downloadManager.getDownloadCount(item.libraryManga.manga) > 0
} }
return@downloaded if (downloadedOnly || filterDownloaded == State.INCLUDE.value) { return@downloaded if (downloadedOnly || filterDownloaded == TriStateGroup.State.INCLUDE.value) {
isDownloaded isDownloaded
} else { } else {
!isDownloaded !isDownloaded
@ -185,10 +198,10 @@ class LibraryPresenter(
} }
val filterFnUnread: (LibraryItem) -> Boolean = unread@{ item -> val filterFnUnread: (LibraryItem) -> Boolean = unread@{ item ->
if (filterUnread == State.IGNORE.value) return@unread true if (filterUnread == TriStateGroup.State.IGNORE.value) return@unread true
val isUnread = item.libraryManga.unreadCount > 0 val isUnread = item.libraryManga.unreadCount > 0
return@unread if (filterUnread == State.INCLUDE.value) { return@unread if (filterUnread == TriStateGroup.State.INCLUDE.value) {
isUnread isUnread
} else { } else {
!isUnread !isUnread
@ -196,10 +209,10 @@ class LibraryPresenter(
} }
val filterFnStarted: (LibraryItem) -> Boolean = started@{ item -> val filterFnStarted: (LibraryItem) -> Boolean = started@{ item ->
if (filterStarted == State.IGNORE.value) return@started true if (filterStarted == TriStateGroup.State.IGNORE.value) return@started true
val hasStarted = item.libraryManga.hasStarted val hasStarted = item.libraryManga.hasStarted
return@started if (filterStarted == State.INCLUDE.value) { return@started if (filterStarted == TriStateGroup.State.INCLUDE.value) {
hasStarted hasStarted
} else { } else {
!hasStarted !hasStarted
@ -207,11 +220,11 @@ class LibraryPresenter(
} }
val filterFnBookmarked: (LibraryItem) -> Boolean = bookmarked@{ item -> val filterFnBookmarked: (LibraryItem) -> Boolean = bookmarked@{ item ->
if (filterBookmarked == State.IGNORE.value) return@bookmarked true if (filterBookmarked == TriStateGroup.State.IGNORE.value) return@bookmarked true
val hasBookmarks = item.libraryManga.hasBookmarks val hasBookmarks = item.libraryManga.hasBookmarks
return@bookmarked if (filterBookmarked == State.INCLUDE.value) { return@bookmarked if (filterBookmarked == TriStateGroup.State.INCLUDE.value) {
hasBookmarks hasBookmarks
} else { } else {
!hasBookmarks !hasBookmarks
@ -219,10 +232,10 @@ class LibraryPresenter(
} }
val filterFnCompleted: (LibraryItem) -> Boolean = completed@{ item -> val filterFnCompleted: (LibraryItem) -> Boolean = completed@{ item ->
if (filterCompleted == State.IGNORE.value) return@completed true if (filterCompleted == TriStateGroup.State.IGNORE.value) return@completed true
val isCompleted = item.libraryManga.manga.status.toInt() == SManga.COMPLETED val isCompleted = item.libraryManga.manga.status.toInt() == SManga.COMPLETED
return@completed if (filterCompleted == State.INCLUDE.value) { return@completed if (filterCompleted == TriStateGroup.State.INCLUDE.value) {
isCompleted isCompleted
} else { } else {
!isCompleted !isCompleted
@ -266,9 +279,7 @@ class LibraryPresenter(
/** /**
* Applies library sorting to the given map of manga. * Applies library sorting to the given map of manga.
*/ */
private fun LibraryMap.applySort(categories: List<Category>): LibraryMap { private fun LibraryMap.applySort(): LibraryMap {
val sortModes = categories.associate { it.id to it.sort }
val locale = Locale.getDefault() val locale = Locale.getDefault()
val collator = Collator.getInstance(locale).apply { val collator = Collator.getInstance(locale).apply {
strength = Collator.PRIMARY strength = Collator.PRIMARY
@ -278,7 +289,7 @@ class LibraryPresenter(
} }
val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
val sort = sortModes[i1.libraryManga.category]!! val sort = keys.find { it.id == i1.libraryManga.category }!!.sort
when (sort.type) { when (sort.type) {
LibrarySort.Type.Alphabetical -> { LibrarySort.Type.Alphabetical -> {
sortAlphabetically(i1, i2) sortAlphabetically(i1, i2)
@ -308,12 +319,11 @@ class LibraryPresenter(
LibrarySort.Type.DateAdded -> { LibrarySort.Type.DateAdded -> {
i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded) i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded)
} }
else -> throw IllegalStateException("Invalid SortModeSetting: ${sort.type}")
} }
} }
return this.mapValues { entry -> return this.mapValues { entry ->
val comparator = if (sortModes[entry.key]!!.isAscending) { val comparator = if (keys.find { it.id == entry.key.id }!!.sort.isAscending) {
Comparator(sortFn) Comparator(sortFn)
} else { } else {
Collections.reverseOrder(sortFn) Collections.reverseOrder(sortFn)
@ -323,24 +333,52 @@ class LibraryPresenter(
} }
} }
private fun getLibraryItemPreferencesFlow(): Flow<ItemPreferences> {
return combine(
libraryPreferences.downloadBadge().changes(),
libraryPreferences.unreadBadge().changes(),
libraryPreferences.localBadge().changes(),
libraryPreferences.languageBadge().changes(),
preferences.downloadedOnly().changes(),
libraryPreferences.filterDownloaded().changes(),
libraryPreferences.filterUnread().changes(),
libraryPreferences.filterStarted().changes(),
libraryPreferences.filterBookmarked().changes(),
libraryPreferences.filterCompleted().changes(),
transform = {
ItemPreferences(
downloadBadge = it[0] as Boolean,
unreadBadge = it[1] as Boolean,
localBadge = it[2] as Boolean,
languageBadge = it[3] as Boolean,
globalFilterDownloaded = it[4] as Boolean,
filterDownloaded = it[5] as Int,
filterUnread = it[6] as Int,
filterStarted = it[7] as Int,
filterBookmarked = it[8] as Int,
filterCompleted = it[9] as Int,
)
},
)
}
/** /**
* Get the categories and all its manga from the database. * Get the categories and all its manga from the database.
* *
* @return an observable of the categories and its manga. * @return an observable of the categories and its manga.
*/ */
private fun getLibraryFlow(): Flow<Library> { private fun getLibraryFlow(): Flow<LibraryMap> {
val libraryMangasFlow = combine( val libraryMangasFlow = combine(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
libraryPreferences.downloadBadge().changes(), getLibraryItemPreferencesFlow(),
libraryPreferences.filterDownloaded().changes(),
preferences.downloadedOnly().changes(),
downloadCache.changes, downloadCache.changes,
) { libraryMangaList, downloadBadgePref, filterDownloadedPref, downloadedOnly, _ -> ) { libraryMangaList, prefs, _ ->
libraryMangaList libraryMangaList
.map { libraryManga -> .map { libraryManga ->
val needsDownloadCounts = downloadBadgePref || val needsDownloadCounts = prefs.downloadBadge ||
filterDownloadedPref != State.IGNORE.value || prefs.filterDownloaded != TriStateGroup.State.IGNORE.value ||
downloadedOnly prefs.globalFilterDownloaded
// Display mode based on user preference: take it from global library setting or category // Display mode based on user preference: take it from global library setting or category
LibraryItem(libraryManga).apply { LibraryItem(libraryManga).apply {
@ -349,39 +387,44 @@ class LibraryPresenter(
} else { } else {
0 0
} }
unreadCount = libraryManga.unreadCount unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0
isLocal = libraryManga.manga.isLocal() isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false
sourceLanguage = sourceManager.getOrStub(libraryManga.manga.source).lang sourceLanguage = if (prefs.languageBadge) {
sourceManager.getOrStub(libraryManga.manga.source).lang
} else {
""
}
} }
} }
.groupBy { it.libraryManga.category } .groupBy { it.libraryManga.category }
} }
return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga -> return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga ->
val displayCategories = if (libraryManga.isNotEmpty() && libraryManga.containsKey(0).not()) { val displayCategories = if (libraryManga.isNotEmpty() && !libraryManga.containsKey(0)) {
categories.fastFilterNot { it.isSystemCategory } categories.fastFilterNot { it.isSystemCategory }
} else { } else {
categories categories
} }
state.categories = displayCategories displayCategories.associateWith { libraryManga[it.id] ?: emptyList() }
Library(categories, libraryManga)
} }
} }
/** /**
* Requests the library to be filtered. * Flow of tracking filter preferences
*
* @return map of track id with the filter value
*/ */
suspend fun requestFilterUpdate() = withIOContext { private fun getTrackingFilterFlow(): Flow<Map<Long, Int>> {
_filterChanges.send(Unit) val loggedServices = trackManager.services.filter { it.isLogged }
} val a = loggedServices
.map { libraryPreferences.filterTracking(it.id.toInt()).changes() }
/** .toTypedArray()
* Called when a manga is opened. return combine(*a) {
*/ loggedServices
fun onOpenManga() { .mapIndexed { index, trackService -> trackService.id to it[index] }
// Avoid further db updates for the library when it's not needed .toMap()
librarySubscription?.cancel() }
} }
/** /**
@ -389,7 +432,7 @@ class LibraryPresenter(
* *
* @param mangas the list of manga. * @param mangas the list of manga.
*/ */
suspend fun getCommonCategories(mangas: List<Manga>): Collection<Category> { private suspend fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList() if (mangas.isEmpty()) return emptyList()
return mangas return mangas
.map { getCategories.await(it.id).toSet() } .map { getCategories.await(it.id).toSet() }
@ -405,13 +448,37 @@ class LibraryPresenter(
* *
* @param mangas the list of manga. * @param mangas the list of manga.
*/ */
suspend fun getMixCategories(mangas: List<Manga>): Collection<Category> { private suspend fun getMixCategories(mangas: List<Manga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList() if (mangas.isEmpty()) return emptyList()
val mangaCategories = mangas.map { getCategories.await(it.id).toSet() } val mangaCategories = mangas.map { getCategories.await(it.id).toSet() }
val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) } val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) }
return mangaCategories.flatten().distinct().subtract(common) return mangaCategories.flatten().distinct().subtract(common)
} }
fun runDownloadActionSelection(action: DownloadAction) {
val selection = state.value.selection
val mangas = selection.map { it.manga }.toList()
when (action) {
DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1)
DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5)
DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10)
DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null)
DownloadAction.CUSTOM -> {
mutableState.update { state ->
state.copy(
dialog = Dialog.DownloadCustomAmount(
mangas,
selection.maxOf { it.unreadCount }.toInt(),
),
)
}
return
}
else -> {}
}
clearSelection()
}
/** /**
* Queues the amount specified of unread chapters from the list of mangas given. * Queues the amount specified of unread chapters from the list of mangas given.
* *
@ -419,7 +486,7 @@ class LibraryPresenter(
* @param amount the amount to queue or null to queue all * @param amount the amount to queue or null to queue all
*/ */
fun downloadUnreadChapters(mangas: List<Manga>, amount: Int?) { fun downloadUnreadChapters(mangas: List<Manga>, amount: Int?) {
presenterScope.launchNonCancellable { coroutineScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getNextChapters.await(manga.id) val chapters = getNextChapters.await(manga.id)
.fastFilterNot { chapter -> .fastFilterNot { chapter ->
@ -440,18 +507,18 @@ class LibraryPresenter(
/** /**
* Marks mangas' chapters read status. * Marks mangas' chapters read status.
*
* @param mangas the list of manga.
*/ */
fun markReadStatus(mangas: List<Manga>, read: Boolean) { fun markReadSelection(read: Boolean) {
presenterScope.launchNonCancellable { val mangas = state.value.selection.toList()
coroutineScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
setReadStatus.await( setReadStatus.await(
manga = manga, manga = manga.manga,
read = read, read = read,
) )
} }
} }
clearSelection()
} }
/** /**
@ -462,7 +529,7 @@ class LibraryPresenter(
* @param deleteChapters whether to delete downloaded chapters. * @param deleteChapters whether to delete downloaded chapters.
*/ */
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) { fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
presenterScope.launchNonCancellable { coroutineScope.launchNonCancellable {
val mangaToDelete = mangaList.distinctBy { it.id } val mangaToDelete = mangaList.distinctBy { it.id }
if (deleteFromLibrary) { if (deleteFromLibrary) {
@ -495,7 +562,7 @@ class LibraryPresenter(
* @param removeCategories the categories to remove in all mangas. * @param removeCategories the categories to remove in all mangas.
*/ */
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) { fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
presenterScope.launchNonCancellable { coroutineScope.launchNonCancellable {
mangaList.forEach { manga -> mangaList.forEach { manga ->
val categoryIds = getCategories.await(manga.id) val categoryIds = getCategories.await(manga.id)
.map { it.id } .map { it.id }
@ -508,148 +575,215 @@ class LibraryPresenter(
} }
} }
@Composable
fun getMangaCountForCategory(categoryId: Long): androidx.compose.runtime.State<Int?> {
return produceState<Int?>(initialValue = null, loadedManga) {
value = loadedManga[categoryId]?.size
}
}
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> { fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState() return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState(coroutineScope)
} }
// TODO: This is good but should we separate title from count or get categories with count from db suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
@Composable return withIOContext {
fun getToolbarTitle(): androidx.compose.runtime.State<LibraryToolbarTitle> { state.value
val category = categories.getOrNull(activeCategory) .getLibraryItemsByCategoryId(activeCategory.toLong())
.randomOrNull()
val defaultTitle = stringResource(R.string.label_library)
val categoryName = category?.visualName ?: defaultTitle
val default = remember { LibraryToolbarTitle(defaultTitle) }
return produceState(initialValue = default, category, loadedManga, mangaCountVisibility, tabVisibility) {
val title = if (tabVisibility.not()) categoryName else defaultTitle
val count = when {
category == null || mangaCountVisibility.not() -> null
tabVisibility.not() -> loadedManga[category.id]?.size
else -> loadedManga.values.flatten().distinctBy { it.libraryManga.manga.id }.size
}
value = when (category) {
null -> default
else -> LibraryToolbarTitle(title, count)
}
}
}
@Composable
fun getMangaForCategory(page: Int): List<LibraryItem> {
val categoryId = remember(categories, page) {
categories.getOrNull(page)?.id ?: -1
}
val unfiltered = remember(loadedManga, categoryId) {
loadedManga[categoryId] ?: emptyList()
}
return remember(unfiltered, searchQuery) {
if (searchQuery.isNullOrBlank()) {
queriedMangaMap.clear()
unfiltered
} else {
unfiltered.fastFilter { it.matches(searchQuery!!) }
.also { queriedMangaMap[categoryId] = it }
}
} }
} }
fun clearSelection() { fun clearSelection() {
state.selection = emptyList() mutableState.update { it.copy(selection = emptyList()) }
} }
fun toggleSelection(manga: LibraryManga) { fun toggleSelection(manga: LibraryManga) {
state.selection = selection.toMutableList().apply { mutableState.update { state ->
if (fastAny { it.id == manga.id }) { val newSelection = state.selection.toMutableList().apply {
removeAll { it.id == manga.id } if (fastAny { it.id == manga.id }) {
} else { removeAll { it.id == manga.id }
add(manga) } else {
add(manga)
}
} }
state.copy(selection = newSelection)
} }
} }
/**
* Map is cleared out via [getMangaForCategory] when [searchQuery] is null or blank
*/
private val queriedMangaMap: MutableMap<Long, List<LibraryItem>> = mutableMapOf()
/**
* Used by select all, inverse and range selection.
*
* If current query is empty then we get manga list from [loadedManga] otherwise from [queriedMangaMap]
*/
private fun getMangaForCategoryWithQuery(categoryId: Long, query: String?): List<LibraryItem> {
return if (query.isNullOrBlank()) loadedManga[categoryId].orEmpty() else queriedMangaMap[categoryId].orEmpty()
}
/** /**
* Selects all mangas between and including the given manga and the last pressed manga from the * Selects all mangas between and including the given manga and the last pressed manga from the
* same category as the given manga * same category as the given manga
*/ */
fun toggleRangeSelection(manga: LibraryManga) { fun toggleRangeSelection(manga: LibraryManga) {
state.selection = selection.toMutableList().apply { mutableState.update { state ->
val lastSelected = lastOrNull() val newSelection = state.selection.toMutableList().apply {
if (lastSelected?.category != manga.category) { val lastSelected = lastOrNull()
add(manga) if (lastSelected?.category != manga.category) {
return@apply add(manga)
} return@apply
}
val items = getMangaForCategoryWithQuery(manga.category, searchQuery) val items = state.getLibraryItemsByCategoryId(manga.category)
.fastMap { it.libraryManga } .fastMap { it.libraryManga }
val lastMangaIndex = items.indexOf(lastSelected) val lastMangaIndex = items.indexOf(lastSelected)
val curMangaIndex = items.indexOf(manga) val curMangaIndex = items.indexOf(manga)
val selectedIds = fastMap { it.id } val selectedIds = fastMap { it.id }
val selectionRange = when { val selectionRange = when {
lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex)
curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex)
// We shouldn't reach this point // We shouldn't reach this point
else -> return@apply else -> return@apply
}
val newSelections = selectionRange.mapNotNull { index ->
items[index].takeUnless { it.id in selectedIds }
}
addAll(newSelections)
} }
val newSelections = selectionRange.mapNotNull { index -> state.copy(selection = newSelection)
items[index].takeUnless { it.id in selectedIds }
}
addAll(newSelections)
} }
} }
fun selectAll(index: Int) { fun selectAll(index: Int) {
state.selection = state.selection.toMutableList().apply { mutableState.update { state ->
val categoryId = categories.getOrNull(index)?.id ?: -1 val newSelection = state.selection.toMutableList().apply {
val selectedIds = fastMap { it.id } val categoryId = state.categories.getOrNull(index)?.id ?: -1
val newSelections = getMangaForCategoryWithQuery(categoryId, searchQuery) val selectedIds = fastMap { it.id }
.fastMapNotNull { item -> val newSelections = state.getLibraryItemsByCategoryId(categoryId)
item.libraryManga.takeUnless { it.id in selectedIds } .fastMapNotNull { item ->
} item.libraryManga.takeUnless { it.id in selectedIds }
}
addAll(newSelections) addAll(newSelections)
}
state.copy(selection = newSelection)
} }
} }
fun invertSelection(index: Int) { fun invertSelection(index: Int) {
state.selection = selection.toMutableList().apply { mutableState.update { state ->
val categoryId = categories[index].id val newSelection = state.selection.toMutableList().apply {
val items = getMangaForCategoryWithQuery(categoryId, searchQuery).fastMap { it.libraryManga } val categoryId = state.categories[index].id
val selectedIds = fastMap { it.id } val items = state.getLibraryItemsByCategoryId(categoryId).fastMap { it.libraryManga }
val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds } val selectedIds = fastMap { it.id }
val toRemoveIds = toRemove.fastMap { it.id } val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds }
removeAll { it.id in toRemoveIds } val toRemoveIds = toRemove.fastMap { it.id }
addAll(toAdd) removeAll { it.id in toRemoveIds }
addAll(toAdd)
}
state.copy(selection = newSelection)
} }
} }
fun search(query: String?) {
mutableState.update { it.copy(searchQuery = query) }
}
fun openChangeCategoryDialog() {
coroutineScope.launchIO {
// Create a copy of selected manga
val mangaList = state.value.selection.map { it.manga }
// Hide the default category because it has a different behavior than the ones from db.
val categories = state.value.categories.filter { it.id != 0L }
// Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = getMixCategories(mangaList)
val preselected = categories.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
}
}
fun openDeleteMangaDialog() {
val mangaList = state.value.selection.map { it.manga }
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
}
fun closeDialog() {
mutableState.update { it.copy(dialog = null) }
}
sealed class Dialog { sealed class Dialog {
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog() data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog()
data class DeleteManga(val manga: List<Manga>) : Dialog() data class DeleteManga(val manga: List<Manga>) : Dialog()
data class DownloadCustomAmount(val manga: List<Manga>, val max: Int) : Dialog() data class DownloadCustomAmount(val manga: List<Manga>, val max: Int) : Dialog()
} }
@Immutable
private data class ItemPreferences(
val downloadBadge: Boolean,
val unreadBadge: Boolean,
val localBadge: Boolean,
val languageBadge: Boolean,
val globalFilterDownloaded: Boolean,
val filterDownloaded: Int,
val filterUnread: Int,
val filterStarted: Int,
val filterBookmarked: Int,
val filterCompleted: Int,
)
@Immutable
data class State(
val isLoading: Boolean = true,
val library: LibraryMap = emptyMap(),
val searchQuery: String? = null,
val selection: List<LibraryManga> = emptyList(),
val hasActiveFilters: Boolean = false,
val showCategoryTabs: Boolean = false,
val showMangaCount: Boolean = false,
val showMangaContinueButton: Boolean = false,
val dialog: Dialog? = null,
) {
val selectionMode = selection.isNotEmpty()
val categories = library.keys.toList()
val libraryCount by lazy {
library
.flatMap { (_, v) -> v }
.distinctBy { it.libraryManga.manga.id }
.size
}
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem> {
return library.firstNotNullOf { (k, v) -> v.takeIf { k.id == categoryId } }
}
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
return library.values.toTypedArray().getOrNull(page) ?: emptyList()
}
fun getMangaCountForCategory(category: Category): Int? {
return library[category]?.size?.takeIf { showMangaCount }
}
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 count = when {
!showMangaCount -> null
!showCategoryTabs -> getMangaCountForCategory(category)
// Whole library count
else -> libraryCount
}
return LibraryToolbarTitle(title, count)
}
}
} }

View file

@ -32,7 +32,6 @@ class LibrarySettingsSheet(
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
private val setDisplayModeForCategory: SetDisplayModeForCategory = Injekt.get(), private val setDisplayModeForCategory: SetDisplayModeForCategory = Injekt.get(),
private val setSortModeForCategory: SetSortModeForCategory = Injekt.get(), private val setSortModeForCategory: SetSortModeForCategory = Injekt.get(),
onGroupClickListener: (ExtendedNavigationView.Group) -> Unit,
) : TabbedBottomSheetDialog(router.activity!!) { ) : TabbedBottomSheetDialog(router.activity!!) {
val filters: Filter val filters: Filter
@ -43,13 +42,8 @@ class LibrarySettingsSheet(
init { init {
filters = Filter(router.activity!!) filters = Filter(router.activity!!)
filters.onGroupClicked = onGroupClickListener
sort = Sort(router.activity!!) sort = Sort(router.activity!!)
sort.onGroupClicked = onGroupClickListener
display = Display(router.activity!!) display = Display(router.activity!!)
display.onGroupClicked = onGroupClickListener
} }
/** /**