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 ContinueReadingButtonGridPadding = 6.dp
private val ContinueReadingButtonListSpacing = 8.dp
private const val GridSelectedCoverAlpha = 0.76f
/**
@ -61,9 +64,8 @@ fun MangaCompactGridItem(
title: String? = null,
coverData: eu.kanade.domain.manga.model.MangaCover,
coverAlpha: Float = 1f,
coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
showContinueReadingButton: Boolean = false,
coverBadgeStart: @Composable (RowScope.() -> Unit)? = null,
coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null,
onLongClick: () -> Unit,
onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null,
@ -86,12 +88,17 @@ fun MangaCompactGridItem(
badgesEnd = coverBadgeEnd,
content = {
if (title != null) {
CoverTextOverlay(title = title, showContinueReadingButton)
}
},
continueReadingButton = {
if (showContinueReadingButton && onClickContinueReading != null) {
ContinueReadingButton(onClickContinueReading)
CoverTextOverlay(
title = title,
onClickContinueReading = onClickContinueReading,
)
} else if (onClickContinueReading != null) {
ContinueReadingButton(
modifier = Modifier
.padding(ContinueReadingButtonGridPadding)
.align(Alignment.BottomEnd),
onClickContinueReading = onClickContinueReading,
)
}
},
)
@ -104,7 +111,7 @@ fun MangaCompactGridItem(
@Composable
private fun BoxScope.CoverTextOverlay(
title: String,
showContinueReadingButton: Boolean = false,
onClickContinueReading: (() -> Unit)? = null,
) {
Box(
modifier = Modifier
@ -119,11 +126,14 @@ private fun BoxScope.CoverTextOverlay(
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
val endPadding = if (showContinueReadingButton) ContinueReadingButtonSize else 0.dp
Row(
modifier = Modifier.align(Alignment.BottomStart),
verticalAlignment = Alignment.Bottom,
) {
GridItemTitle(
modifier = Modifier
.padding(start = 8.dp, top = 8.dp, end = endPadding + 8.dp, bottom = 8.dp)
.align(Alignment.BottomStart),
.weight(1f)
.padding(8.dp),
title = title,
style = MaterialTheme.typography.titleSmall.copy(
color = Color.White,
@ -133,6 +143,16 @@ private fun BoxScope.CoverTextOverlay(
),
),
)
if (onClickContinueReading != null) {
ContinueReadingButton(
modifier = Modifier.padding(
end = ContinueReadingButtonGridPadding,
bottom = ContinueReadingButtonGridPadding,
),
onClickContinueReading = onClickContinueReading,
)
}
}
}
/**
@ -146,7 +166,6 @@ fun MangaComfortableGridItem(
coverAlpha: Float = 1f,
coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
showContinueReadingButton: Boolean = false,
onLongClick: () -> Unit,
onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null,
@ -168,9 +187,14 @@ fun MangaComfortableGridItem(
},
badgesStart = coverBadgeStart,
badgesEnd = coverBadgeEnd,
continueReadingButton = {
if (showContinueReadingButton && onClickContinueReading != null) {
ContinueReadingButton(onClickContinueReading)
content = {
if (onClickContinueReading != null) {
ContinueReadingButton(
modifier = Modifier
.padding(ContinueReadingButtonGridPadding)
.align(Alignment.BottomEnd),
onClickContinueReading = onClickContinueReading,
)
}
},
)
@ -192,7 +216,6 @@ private fun MangaGridCover(
cover: @Composable BoxScope.() -> Unit = {},
badgesStart: (@Composable RowScope.() -> Unit)? = null,
badgesEnd: (@Composable RowScope.() -> Unit)? = null,
continueReadingButton: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable (BoxScope.() -> Unit)? = null,
) {
Box(
@ -219,7 +242,6 @@ private fun MangaGridCover(
content = badgesEnd,
)
}
continueReadingButton?.invoke(this)
}
}
@ -310,8 +332,7 @@ fun MangaListItem(
title: String,
coverData: eu.kanade.domain.manga.model.MangaCover,
coverAlpha: Float = 1f,
badge: @Composable RowScope.() -> Unit,
showContinueReadingButton: Boolean = false,
badge: @Composable (RowScope.() -> Unit),
onLongClick: () -> Unit,
onClick: () -> Unit,
onClickContinueReading: (() -> Unit)? = null,
@ -343,23 +364,21 @@ fun MangaListItem(
style = MaterialTheme.typography.bodyMedium,
)
BadgeGroup(content = badge)
if (showContinueReadingButton && onClickContinueReading != null) {
Box {
ContinueReadingButton(onClickContinueReading)
}
if (onClickContinueReading != null) {
ContinueReadingButton(
modifier = Modifier.padding(start = ContinueReadingButtonListSpacing),
onClickContinueReading = onClickContinueReading,
)
}
}
}
@Composable
private fun BoxScope.ContinueReadingButton(
private fun ContinueReadingButton(
modifier: Modifier = Modifier,
onClickContinueReading: () -> Unit,
) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
Box(modifier = modifier) {
FilledIconButton(
onClick = onClickContinueReading,
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 eu.kanade.presentation.components.Badge
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun DownloadsBadge(
enabled: Boolean,
item: LibraryItem,
) {
if (enabled && item.downloadCount > 0) {
fun DownloadsBadge(count: Int) {
if (count > 0) {
Badge(
text = "${item.downloadCount}",
text = "$count",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
@ -22,30 +18,26 @@ fun DownloadsBadge(
}
@Composable
fun UnreadBadge(
enabled: Boolean,
item: LibraryItem,
) {
if (enabled && item.unreadCount > 0) {
Badge(text = "${item.unreadCount}")
fun UnreadBadge(count: Int) {
if (count > 0) {
Badge(text = "$count")
}
}
@Composable
fun LanguageBadge(
showLanguage: Boolean,
showLocal: Boolean,
item: LibraryItem,
isLocal: Boolean,
sourceLanguage: String,
) {
if (showLocal && item.isLocal) {
if (isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
} else if (showLanguage && item.sourceLanguage.isNotEmpty()) {
} else if (sourceLanguage.isNotEmpty()) {
Badge(
text = item.sourceLanguage.uppercase(),
text = sourceLanguage.uppercase(),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)

View file

@ -14,17 +14,12 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryComfortableGrid(
items: List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
columns: Int,
contentPadding: PaddingValues,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) {
@ -51,26 +46,22 @@ fun LibraryComfortableGrid(
lastModified = manga.coverLastModified,
),
coverBadgeStart = {
DownloadsBadge(
enabled = showDownloadBadges,
item = libraryItem,
)
UnreadBadge(
enabled = showUnreadBadges,
item = libraryItem,
)
DownloadsBadge(count = libraryItem.downloadCount.toInt())
UnreadBadge(count = libraryItem.unreadCount.toInt())
},
coverBadgeEnd = {
LanguageBadge(
showLanguage = showLanguageBadges,
showLocal = showLocalBadges,
item = libraryItem,
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,
)
},
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(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(
items: List<LibraryItem>,
showTitle: Boolean,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
columns: Int,
contentPadding: PaddingValues,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) {
@ -52,26 +47,22 @@ fun LibraryCompactGrid(
lastModified = manga.coverLastModified,
),
coverBadgeStart = {
DownloadsBadge(
enabled = showDownloadBadges,
item = libraryItem,
)
UnreadBadge(
enabled = showUnreadBadges,
item = libraryItem,
)
DownloadsBadge(count = libraryItem.downloadCount.toInt())
UnreadBadge(count = libraryItem.unreadCount.toInt())
},
coverBadgeEnd = {
LanguageBadge(
showLanguage = showLanguageBadges,
showLocal = showLocalBadges,
item = libraryItem,
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,
)
},
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.presentation.components.SwipeRefresh
import eu.kanade.presentation.components.rememberPagerState
import eu.kanade.presentation.library.LibraryState
import eu.kanade.tachiyomi.ui.library.LibraryItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -29,28 +27,24 @@ import kotlin.time.Duration.Companion.seconds
@Composable
fun LibraryContent(
state: LibraryState,
categories: List<Category>,
searchQuery: String?,
selection: List<LibraryManga>,
contentPadding: PaddingValues,
currentPage: () -> Int,
isLibraryEmpty: Boolean,
showPageTabs: Boolean,
showMangaCount: Boolean,
onChangeCurrentPage: (Int) -> Unit,
onMangaClicked: (Long) -> Unit,
onContinueReadingClicked: (LibraryManga) -> Unit,
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
onToggleSelection: (LibraryManga) -> Unit,
onToggleRangeSelection: (LibraryManga) -> Unit,
onRefresh: (Category?) -> Boolean,
onGlobalSearchClicked: () -> Unit,
getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>,
getNumberOfMangaForCategory: (Category) -> Int?,
getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
getLibraryForPage: (Int) -> List<LibraryItem>,
isDownloadOnly: Boolean,
isIncognitoMode: Boolean,
) {
@ -61,38 +55,30 @@ fun LibraryContent(
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
),
) {
val categories = state.categories
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
val pagerState = rememberPagerState(coercedCurrentPage)
val scope = rememberCoroutineScope()
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (isLibraryEmpty.not() && showPageTabs && categories.size > 1) {
if (!isLibraryEmpty && showPageTabs && categories.size > 1) {
LibraryTabs(
categories = categories,
currentPageIndex = pagerState.currentPage,
showMangaCount = showMangaCount,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
isDownloadOnly = isDownloadOnly,
isIncognitoMode = isIncognitoMode,
onTabItemClick = { scope.launch { pagerState.animateScrollToPage(it) } },
)
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
) { scope.launch { pagerState.animateScrollToPage(it) } }
}
val notSelectionMode = selection.isEmpty()
val onClickManga = { manga: LibraryManga ->
if (state.selectionMode.not()) {
if (notSelectionMode) {
onMangaClicked(manga.manga.id)
} else {
onToggleSelection(manga)
}
}
val onLongClickManga = { manga: LibraryManga ->
onToggleRangeSelection(manga)
}
val onClickContinueReading = { manga: LibraryManga ->
onContinueReadingClicked(manga)
}
SwipeRefresh(
refreshing = isRefreshing,
@ -106,26 +92,21 @@ fun LibraryContent(
isRefreshing = false
}
},
enabled = state.selectionMode.not(),
enabled = notSelectionMode,
) {
LibraryPager(
state = pagerState,
contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()),
pageCount = categories.size,
selectedManga = state.selection,
selectedManga = selection,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
getDisplayModeForPage = getDisplayModeForPage,
getColumnsForOrientation = getColumnsForOrientation,
getLibraryForPage = getLibraryForPage,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
onClickManga = onClickManga,
onLongClickManga = onLongClickManga,
onClickContinueReading = onClickContinueReading,
onGlobalSearchClicked = onGlobalSearchClicked,
searchQuery = state.searchQuery,
onLongClickManga = onToggleRangeSelection,
onClickContinueReading = onContinueReadingClicked,
)
}

View file

@ -23,16 +23,11 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryList(
items: List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
contentPadding: PaddingValues,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) {
@ -41,13 +36,13 @@ fun LibraryList(
contentPadding = contentPadding + PaddingValues(vertical = 8.dp),
) {
item {
if (searchQuery.isNullOrEmpty().not()) {
if (!searchQuery.isNullOrEmpty()) {
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onGlobalSearchClicked,
) {
Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!),
text = stringResource(R.string.action_global_search_query, searchQuery),
modifier = Modifier.zIndex(99f),
)
}
@ -70,14 +65,20 @@ fun LibraryList(
lastModified = manga.coverLastModified,
),
badge = {
DownloadsBadge(enabled = showDownloadBadges, item = libraryItem)
UnreadBadge(enabled = showUnreadBadges, item = libraryItem)
LanguageBadge(showLanguage = showLanguageBadges, showLocal = showLocalBadges, item = libraryItem)
DownloadsBadge(count = libraryItem.downloadCount.toInt())
UnreadBadge(count = libraryItem.unreadCount.toInt())
LanguageBadge(
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,
)
},
showContinueReadingButton = showContinueReadingButton,
onLongClick = { onLongClick(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,
getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> List<LibraryItem>,
showDownloadBadges: Boolean,
showUnreadBadges: Boolean,
showLocalBadges: Boolean,
showLanguageBadges: Boolean,
showContinueReadingButton: Boolean,
getLibraryForPage: (Int) -> List<LibraryItem>,
onClickManga: (LibraryManga) -> Unit,
onLongClickManga: (LibraryManga) -> Unit,
onClickContinueReading: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?,
) {
HorizontalPager(
count = pageCount,
@ -62,11 +57,6 @@ fun LibraryPager(
LibraryDisplayMode.List -> {
LibraryList(
items = library,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
contentPadding = contentPadding,
selection = selectedManga,
onClick = onClickManga,
@ -80,11 +70,6 @@ fun LibraryPager(
LibraryCompactGrid(
items = library,
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
columns = columns,
contentPadding = contentPadding,
selection = selectedManga,
@ -98,17 +83,12 @@ fun LibraryPager(
LibraryDisplayMode.ComfortableGrid -> {
LibraryComfortableGrid(
items = library,
showDownloadBadges = showDownloadBadges,
showUnreadBadges = showUnreadBadges,
showLocalBadges = showLocalBadges,
showLanguageBadges = showLanguageBadges,
showContinueReadingButton = showContinueReadingButton,
columns = columns,
contentPadding = contentPadding,
selection = selectedManga,
onClick = onClickManga,
onClickContinueReading = onClickContinueReading,
onLongClick = onLongClickManga,
onClickContinueReading = onClickContinueReading,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
)

View file

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

View file

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

View file

@ -1,167 +1,37 @@
package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import android.view.Menu
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.bluelinelabs.conductor.ControllerChangeHandler
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 cafe.adriel.voyager.navigator.Navigator
import eu.kanade.domain.category.model.Category
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
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.flow.collectLatest
import kotlinx.coroutines.launch
class LibraryController(
bundle: Bundle? = null,
) : FullComposeController<LibraryPresenter>(bundle), RootController {
) : BasicFullComposeController(bundle), RootController {
/**
* Sheet containing filter/sort/display items.
*/
private var settingsSheet: LibrarySettingsSheet? = null
override fun createPresenter(): LibraryPresenter = LibraryPresenter()
@Composable
override fun ComposeContent() {
val context = LocalContext.current
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
}
Navigator(screen = LibraryScreen)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
settingsSheet = LibrarySettingsSheet(router) { group ->
when (group) {
is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged()
else -> {} // Handled via different mechanisms
}
}
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
presenter.subscribeLibrary()
settingsSheet = LibrarySettingsSheet(router)
viewScope.launch {
LibraryScreen.openSettingsSheetEvent
.collectLatest(::showSettingsSheet)
}
}
@ -171,111 +41,13 @@ class LibraryController(
super.onDestroyView(view)
}
fun showSettingsSheet() {
presenter.categories.getOrNull(presenter.activeCategory)?.let { category ->
fun showSettingsSheet(category: Category? = null) {
if (category != null) {
settingsSheet?.show(category)
}
}
private fun onFilterChanged() {
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))
viewScope.launch { LibraryScreen.requestOpenSettingsSheet() }
}
}
/**
* 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)
}
fun search(query: String) = LibraryScreen.search(query)
}

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
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.Immutable
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.ui.res.stringResource
import androidx.compose.ui.util.fastAny
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.PreferenceMutableState
import eu.kanade.core.prefs.asState
import eu.kanade.core.util.fastFilter
import eu.kanade.core.util.fastFilterNot
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.isLocal
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.tachiyomi.R
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache
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.model.SManga
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.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.distinctUntilChanged
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.api.get
import java.text.Collator
import java.util.Collections
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 LibraryMap = Map<Long, List<LibraryItem>>
typealias LibraryMap = Map<Category, List<LibraryItem>>
class LibraryPresenter(
private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
class LibraryScreenModel(
private val getLibraryManga: GetLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
@ -94,90 +83,114 @@ class LibraryPresenter(
private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = 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()
val mangaCountVisibility by libraryPreferences.categoryNumberOfItems().asState()
val showDownloadBadges by libraryPreferences.downloadBadge().asState()
val showUnreadBadges by libraryPreferences.unreadBadge().asState()
val showLocalBadges by libraryPreferences.localBadge().asState()
val showLanguageBadges by libraryPreferences.languageBadge().asState()
var activeCategory: Int by libraryPreferences.lastUsedCategory().asState()
val showContinueReadingButton by libraryPreferences.showContinueReadingButton().asState()
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
private val _filterChanges: Channel<Unit> = Channel(Int.MAX_VALUE)
private val filterChanges = _filterChanges.receiveAsFlow().onStart { emit(Unit) }
private var librarySubscription: Job? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()
init {
coroutineScope.launchIO {
combine(
state.map { it.searchQuery }.distinctUntilChanged(),
getLibraryFlow(),
getTracksPerManga.subscribe(),
getTrackingFilterFlow(),
) { searchQuery, library, tracks, loggedInTrackServices ->
library
.applyFilters(tracks, loggedInTrackServices)
.applySort()
.mapValues { (_, value) ->
if (searchQuery != null) {
// Filter query
value.filter { it.matches(searchQuery) }
} else {
// Don't do anything
value
}
}
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
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.
*/
private fun LibraryMap.applyFilters(trackMap: Map<Long, List<Long>>): LibraryMap {
val downloadedOnly = preferences.downloadedOnly().get()
val filterDownloaded = libraryPreferences.filterDownloaded().get()
val filterUnread = libraryPreferences.filterUnread().get()
val filterStarted = libraryPreferences.filterStarted().get()
val filterBookmarked = libraryPreferences.filterBookmarked().get()
val filterCompleted = libraryPreferences.filterCompleted().get()
private suspend fun LibraryMap.applyFilters(
trackMap: Map<Long, List<Long>>,
loggedInTrackServices: Map<Long, Int>,
): LibraryMap {
val prefs = getLibraryItemPreferencesFlow().first()
val downloadedOnly = prefs.globalFilterDownloaded
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 excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.EXCLUDE.value) it.key else null }
val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.INCLUDE.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 == TriStateGroup.State.INCLUDE.value) it.key else null }
val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty()
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 {
item.libraryManga.manga.isLocal() -> true
item.downloadCount != -1L -> item.downloadCount > 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
} else {
!isDownloaded
@ -185,10 +198,10 @@ class LibraryPresenter(
}
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
return@unread if (filterUnread == State.INCLUDE.value) {
return@unread if (filterUnread == TriStateGroup.State.INCLUDE.value) {
isUnread
} else {
!isUnread
@ -196,10 +209,10 @@ class LibraryPresenter(
}
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
return@started if (filterStarted == State.INCLUDE.value) {
return@started if (filterStarted == TriStateGroup.State.INCLUDE.value) {
hasStarted
} else {
!hasStarted
@ -207,11 +220,11 @@ class LibraryPresenter(
}
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
return@bookmarked if (filterBookmarked == State.INCLUDE.value) {
return@bookmarked if (filterBookmarked == TriStateGroup.State.INCLUDE.value) {
hasBookmarks
} else {
!hasBookmarks
@ -219,10 +232,10 @@ class LibraryPresenter(
}
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
return@completed if (filterCompleted == State.INCLUDE.value) {
return@completed if (filterCompleted == TriStateGroup.State.INCLUDE.value) {
isCompleted
} else {
!isCompleted
@ -266,9 +279,7 @@ class LibraryPresenter(
/**
* Applies library sorting to the given map of manga.
*/
private fun LibraryMap.applySort(categories: List<Category>): LibraryMap {
val sortModes = categories.associate { it.id to it.sort }
private fun LibraryMap.applySort(): LibraryMap {
val locale = Locale.getDefault()
val collator = Collator.getInstance(locale).apply {
strength = Collator.PRIMARY
@ -278,7 +289,7 @@ class LibraryPresenter(
}
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) {
LibrarySort.Type.Alphabetical -> {
sortAlphabetically(i1, i2)
@ -308,12 +319,11 @@ class LibraryPresenter(
LibrarySort.Type.DateAdded -> {
i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded)
}
else -> throw IllegalStateException("Invalid SortModeSetting: ${sort.type}")
}
}
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)
} else {
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.
*
* @return an observable of the categories and its manga.
*/
private fun getLibraryFlow(): Flow<Library> {
private fun getLibraryFlow(): Flow<LibraryMap> {
val libraryMangasFlow = combine(
getLibraryManga.subscribe(),
libraryPreferences.downloadBadge().changes(),
libraryPreferences.filterDownloaded().changes(),
preferences.downloadedOnly().changes(),
getLibraryItemPreferencesFlow(),
downloadCache.changes,
) { libraryMangaList, downloadBadgePref, filterDownloadedPref, downloadedOnly, _ ->
) { libraryMangaList, prefs, _ ->
libraryMangaList
.map { libraryManga ->
val needsDownloadCounts = downloadBadgePref ||
filterDownloadedPref != State.IGNORE.value ||
downloadedOnly
val needsDownloadCounts = prefs.downloadBadge ||
prefs.filterDownloaded != TriStateGroup.State.IGNORE.value ||
prefs.globalFilterDownloaded
// Display mode based on user preference: take it from global library setting or category
LibraryItem(libraryManga).apply {
@ -349,39 +387,44 @@ class LibraryPresenter(
} else {
0
}
unreadCount = libraryManga.unreadCount
isLocal = libraryManga.manga.isLocal()
sourceLanguage = sourceManager.getOrStub(libraryManga.manga.source).lang
unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0
isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false
sourceLanguage = if (prefs.languageBadge) {
sourceManager.getOrStub(libraryManga.manga.source).lang
} else {
""
}
}
}
.groupBy { it.libraryManga.category }
}
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 }
} else {
categories
}
state.categories = displayCategories
Library(categories, libraryManga)
displayCategories.associateWith { libraryManga[it.id] ?: emptyList() }
}
}
/**
* Requests the library to be filtered.
* Flow of tracking filter preferences
*
* @return map of track id with the filter value
*/
suspend fun requestFilterUpdate() = withIOContext {
_filterChanges.send(Unit)
private fun getTrackingFilterFlow(): Flow<Map<Long, Int>> {
val loggedServices = trackManager.services.filter { it.isLogged }
val a = loggedServices
.map { libraryPreferences.filterTracking(it.id.toInt()).changes() }
.toTypedArray()
return combine(*a) {
loggedServices
.mapIndexed { index, trackService -> trackService.id to it[index] }
.toMap()
}
/**
* Called when a manga is opened.
*/
fun onOpenManga() {
// Avoid further db updates for the library when it's not needed
librarySubscription?.cancel()
}
/**
@ -389,7 +432,7 @@ class LibraryPresenter(
*
* @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()
return mangas
.map { getCategories.await(it.id).toSet() }
@ -405,13 +448,37 @@ class LibraryPresenter(
*
* @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()
val mangaCategories = mangas.map { getCategories.await(it.id).toSet() }
val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) }
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.
*
@ -419,7 +486,7 @@ class LibraryPresenter(
* @param amount the amount to queue or null to queue all
*/
fun downloadUnreadChapters(mangas: List<Manga>, amount: Int?) {
presenterScope.launchNonCancellable {
coroutineScope.launchNonCancellable {
mangas.forEach { manga ->
val chapters = getNextChapters.await(manga.id)
.fastFilterNot { chapter ->
@ -440,18 +507,18 @@ class LibraryPresenter(
/**
* Marks mangas' chapters read status.
*
* @param mangas the list of manga.
*/
fun markReadStatus(mangas: List<Manga>, read: Boolean) {
presenterScope.launchNonCancellable {
fun markReadSelection(read: Boolean) {
val mangas = state.value.selection.toList()
coroutineScope.launchNonCancellable {
mangas.forEach { manga ->
setReadStatus.await(
manga = manga,
manga = manga.manga,
read = read,
)
}
}
clearSelection()
}
/**
@ -462,7 +529,7 @@ class LibraryPresenter(
* @param deleteChapters whether to delete downloaded chapters.
*/
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
presenterScope.launchNonCancellable {
coroutineScope.launchNonCancellable {
val mangaToDelete = mangaList.distinctBy { it.id }
if (deleteFromLibrary) {
@ -495,7 +562,7 @@ class LibraryPresenter(
* @param removeCategories the categories to remove in all mangas.
*/
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
presenterScope.launchNonCancellable {
coroutineScope.launchNonCancellable {
mangaList.forEach { manga ->
val categoryIds = getCategories.await(manga.id)
.map { it.id }
@ -508,87 +575,33 @@ 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> {
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
@Composable
fun getToolbarTitle(): androidx.compose.runtime.State<LibraryToolbarTitle> {
val category = categories.getOrNull(activeCategory)
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 }
}
suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
return withIOContext {
state.value
.getLibraryItemsByCategoryId(activeCategory.toLong())
.randomOrNull()
}
}
fun clearSelection() {
state.selection = emptyList()
mutableState.update { it.copy(selection = emptyList()) }
}
fun toggleSelection(manga: LibraryManga) {
state.selection = selection.toMutableList().apply {
mutableState.update { state ->
val newSelection = state.selection.toMutableList().apply {
if (fastAny { it.id == manga.id }) {
removeAll { it.id == manga.id }
} 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()
}
/**
@ -596,14 +609,15 @@ class LibraryPresenter(
* same category as the given manga
*/
fun toggleRangeSelection(manga: LibraryManga) {
state.selection = selection.toMutableList().apply {
mutableState.update { state ->
val newSelection = state.selection.toMutableList().apply {
val lastSelected = lastOrNull()
if (lastSelected?.category != manga.category) {
add(manga)
return@apply
}
val items = getMangaForCategoryWithQuery(manga.category, searchQuery)
val items = state.getLibraryItemsByCategoryId(manga.category)
.fastMap { it.libraryManga }
val lastMangaIndex = items.indexOf(lastSelected)
val curMangaIndex = items.indexOf(manga)
@ -620,31 +634,75 @@ class LibraryPresenter(
}
addAll(newSelections)
}
state.copy(selection = newSelection)
}
}
fun selectAll(index: Int) {
state.selection = state.selection.toMutableList().apply {
val categoryId = categories.getOrNull(index)?.id ?: -1
mutableState.update { state ->
val newSelection = state.selection.toMutableList().apply {
val categoryId = state.categories.getOrNull(index)?.id ?: -1
val selectedIds = fastMap { it.id }
val newSelections = getMangaForCategoryWithQuery(categoryId, searchQuery)
val newSelections = state.getLibraryItemsByCategoryId(categoryId)
.fastMapNotNull { item ->
item.libraryManga.takeUnless { it.id in selectedIds }
}
addAll(newSelections)
}
state.copy(selection = newSelection)
}
}
fun invertSelection(index: Int) {
state.selection = selection.toMutableList().apply {
val categoryId = categories[index].id
val items = getMangaForCategoryWithQuery(categoryId, searchQuery).fastMap { it.libraryManga }
mutableState.update { state ->
val newSelection = state.selection.toMutableList().apply {
val categoryId = state.categories[index].id
val items = state.getLibraryItemsByCategoryId(categoryId).fastMap { it.libraryManga }
val selectedIds = fastMap { it.id }
val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds }
val toRemoveIds = toRemove.fastMap { it.id }
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 {
@ -652,4 +710,80 @@ class LibraryPresenter(
data class DeleteManga(val manga: List<Manga>) : 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 setDisplayModeForCategory: SetDisplayModeForCategory = Injekt.get(),
private val setSortModeForCategory: SetSortModeForCategory = Injekt.get(),
onGroupClickListener: (ExtendedNavigationView.Group) -> Unit,
) : TabbedBottomSheetDialog(router.activity!!) {
val filters: Filter
@ -43,13 +42,8 @@ class LibrarySettingsSheet(
init {
filters = Filter(router.activity!!)
filters.onGroupClicked = onGroupClickListener
sort = Sort(router.activity!!)
sort.onGroupClicked = onGroupClickListener
display = Display(router.activity!!)
display.onGroupClicked = onGroupClickListener
}
/**