Use Compose for Library screen (#7557)

- Move Pager to Compose
- Move AppBar to Compose
- Use Stable interface for state
- Use pills for no. of manga in category instead of (x)
This commit is contained in:
Andreas 2022-07-23 01:05:50 +02:00 committed by GitHub
parent e8b7743826
commit 2b8d1bcc02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 973 additions and 687 deletions

View file

@ -161,6 +161,8 @@ dependencies {
implementation(compose.accompanist.webview) implementation(compose.accompanist.webview)
implementation(compose.accompanist.swiperefresh) implementation(compose.accompanist.swiperefresh)
implementation(compose.accompanist.flowlayout) implementation(compose.accompanist.flowlayout)
implementation(compose.accompanist.pager.core)
implementation(compose.accompanist.pager.indicators)
implementation(androidx.paging.runtime) implementation(androidx.paging.runtime)
implementation(androidx.paging.compose) implementation(androidx.paging.compose)
@ -302,6 +304,7 @@ tasks {
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi", "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
"-opt-in=com.google.accompanist.pager.ExperimentalPagerApi"
) )
} }

View file

@ -38,8 +38,8 @@ fun Badge(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.background(color) .clip(shape)
.clip(shape), .background(color),
) { ) {
Text( Text(
text = text, text = text,

View file

@ -195,3 +195,91 @@ private fun RowScope.Button(
} }
} }
} }
@Composable
fun LibraryBottomActionMenu(
visible: Boolean,
modifier: Modifier = Modifier,
onChangeCategoryClicked: (() -> Unit)?,
onMarkAsReadClicked: (() -> Unit)?,
onMarkAsUnreadClicked: (() -> Unit)?,
onDownloadClicked: (() -> Unit)?,
onDeleteClicked: (() -> Unit)?,
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(expandFrom = Alignment.Bottom),
exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
) {
val scope = rememberCoroutineScope()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
tonalElevation = 3.dp,
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0 until 5).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1000)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
if (onChangeCategoryClicked != null) {
Button(
title = stringResource(R.string.action_move_category),
icon = Icons.Default.BookmarkAdd,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = onChangeCategoryClicked,
)
}
if (onMarkAsReadClicked != null) {
Button(
title = stringResource(R.string.action_mark_as_read),
icon = Icons.Default.DoneAll,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = onMarkAsReadClicked,
)
}
if (onMarkAsUnreadClicked != null) {
Button(
title = stringResource(R.string.action_mark_as_unread),
icon = Icons.Default.RemoveDone,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = onMarkAsUnreadClicked,
)
}
if (onDownloadClicked != null) {
Button(
title = stringResource(R.string.action_download),
icon = Icons.Outlined.Download,
toConfirm = confirm[3],
onLongClick = { onLongClickItem(3) },
onClick = onDownloadClicked,
)
}
if (onDeleteClicked != null) {
Button(
title = stringResource(R.string.action_delete),
icon = Icons.Outlined.Delete,
toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) },
onClick = onDeleteClicked,
)
}
}
}
}
}

View file

@ -0,0 +1,38 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@Composable
fun Pill(
text: String,
modifier: Modifier = Modifier,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.background,
contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onBackground,
elevation: Dp = 1.dp,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
) {
androidx.compose.material3.Surface(
modifier = modifier
.padding(start = 4.dp)
.clip(RoundedCornerShape(100)),
color = color,
contentColor = contentColor,
tonalElevation = elevation,
) {
Text(
text = text,
modifier = Modifier.padding(6.dp, 1.dp),
fontSize = fontSize,
)
}
}

View file

@ -0,0 +1,71 @@
package eu.kanade.presentation.library
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import eu.kanade.presentation.components.LibraryBottomActionMenu
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.library.components.LibraryContent
import eu.kanade.presentation.library.components.LibraryToolbar
import eu.kanade.tachiyomi.ui.library.LibraryPresenter
@Composable
fun LibraryScreen(
presenter: LibraryPresenter,
onMangaClicked: (Long) -> Unit,
onGlobalSearchClicked: () -> Unit,
onChangeCategoryClicked: () -> Unit,
onMarkAsReadClicked: () -> Unit,
onMarkAsUnreadClicked: () -> Unit,
onDownloadClicked: () -> Unit,
onDeleteClicked: () -> Unit,
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
onClickFilter: () -> Unit,
onClickRefresh: () -> Unit,
) {
Scaffold(
topBar = {
val title by presenter.getToolbarTitle()
LibraryToolbar(
state = presenter,
title = title,
onClickUnselectAll = onClickUnselectAll,
onClickSelectAll = onClickSelectAll,
onClickInvertSelection = onClickInvertSelection,
onClickFilter = onClickFilter,
onClickRefresh = onClickRefresh,
)
},
bottomBar = {
LibraryBottomActionMenu(
visible = presenter.selectionMode,
onChangeCategoryClicked = onChangeCategoryClicked,
onMarkAsReadClicked = onMarkAsReadClicked,
onMarkAsUnreadClicked = onMarkAsUnreadClicked,
onDownloadClicked = onDownloadClicked,
onDeleteClicked = onDeleteClicked,
)
},
) { paddingValues ->
LibraryContent(
state = presenter,
contentPadding = paddingValues,
currentPage = presenter.activeCategory,
isLibraryEmpty = presenter.loadedManga.isEmpty(),
showPageTabs = presenter.tabVisibility,
showMangaCount = presenter.mangaCountVisibility,
onChangeCurrentPage = { presenter.activeCategory = it },
onMangaClicked = onMangaClicked,
onToggleSelection = { presenter.toggleSelection(it) },
onRefresh = onClickRefresh,
onGlobalSearchClicked = onGlobalSearchClicked,
getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) },
getDisplayModeForPage = { presenter.getDisplayMode(index = it) },
getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) },
getLibraryForPage = { presenter.getMangaForCategory(page = it) },
isIncognitoMode = presenter.isIncognitoMode,
isDownloadOnly = presenter.isDownloadOnly,
)
}
}

View file

@ -0,0 +1,32 @@
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.tachiyomi.data.database.models.LibraryManga
@Stable
interface LibraryState {
val isLoading: Boolean
val categories: List<Category>
var searchQuery: String?
val selection: List<LibraryManga>
val selectionMode: Boolean
var hasActiveFilters: Boolean
}
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)
}

View file

@ -3,14 +3,19 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import eu.kanade.domain.manga.model.MangaCover import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.components.TextButton
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
@ -21,10 +26,22 @@ fun LibraryComfortableGrid(
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) { ) {
LazyLibraryGrid( LazyLibraryGrid(
columns = columns, columns = columns,
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (searchQuery.isNullOrEmpty().not()) {
TextButton(onClick = onGlobalSearchClicked) {
Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!),
modifier = Modifier.zIndex(99f),
)
}
}
}
items( items(
items = items, items = items,
key = { key = {

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
@ -17,8 +18,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import eu.kanade.presentation.components.TextButton
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
@ -29,10 +34,23 @@ fun LibraryCompactGrid(
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) { ) {
LazyLibraryGrid( LazyLibraryGrid(
columns = columns, columns = columns,
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (searchQuery.isNullOrEmpty().not()) {
TextButton(onClick = onGlobalSearchClicked) {
Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!),
modifier = Modifier.zIndex(99f),
)
}
}
}
items( items(
items = items, items = items,
key = { key = {

View file

@ -0,0 +1,126 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import com.google.accompanist.pager.rememberPagerState
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.library.LibraryState
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.widget.EmptyView
@Composable
fun LibraryContent(
state: LibraryState,
contentPadding: PaddingValues,
currentPage: Int,
isLibraryEmpty: Boolean,
isDownloadOnly: Boolean,
isIncognitoMode: Boolean,
showPageTabs: Boolean,
showMangaCount: Boolean,
onChangeCurrentPage: (Int) -> Unit,
onMangaClicked: (Long) -> Unit,
onToggleSelection: (LibraryManga) -> Unit,
onRefresh: () -> Unit,
onGlobalSearchClicked: () -> Unit,
getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>,
getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>,
) {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val pagerState = rememberPagerState(currentPage)
val categories = state.categories
if (categories.isEmpty()) {
LoadingScreen()
return
}
Column(
modifier = Modifier.padding(contentPadding),
) {
if (showPageTabs && categories.size > 1) {
LibraryTabs(
state = pagerState,
categories = state.categories,
showMangaCount = showMangaCount,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
isDownloadOnly = isDownloadOnly,
isIncognitoMode = isIncognitoMode,
)
}
val onClickManga = { manga: LibraryManga ->
if (state.selectionMode.not()) {
onMangaClicked(manga.id!!)
} else {
onToggleSelection(manga)
}
}
val onLongClickManga = { manga: LibraryManga ->
onToggleSelection(manga)
}
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = false),
modifier = Modifier.nestedScroll(nestedScrollInterop),
onRefresh = onRefresh,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
if (state.searchQuery.isNullOrEmpty() && isLibraryEmpty) {
val context = LocalContext.current
EmptyScreen(
R.string.information_empty_library,
listOf(
EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) {
context.openInBrowser("https://tachiyomi.org/help/guides/getting-started")
},
),
)
return@SwipeRefresh
}
LibraryPager(
state = pagerState,
pageCount = categories.size,
selectedManga = state.selection,
getDisplayModeForPage = getDisplayModeForPage,
getColumnsForOrientation = getColumnsForOrientation,
getLibraryForPage = getLibraryForPage,
onClickManga = onClickManga,
onLongClickManga = onLongClickManga,
onGlobalSearchClicked = onGlobalSearchClicked,
searchQuery = state.searchQuery,
)
}
LaunchedEffect(pagerState.currentPage) {
onChangeCurrentPage(pagerState.currentPage)
}
}
}

View file

@ -1,9 +1,15 @@
package eu.kanade.presentation.library.components package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.zIndex
import eu.kanade.presentation.components.TextButton
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
@ -14,10 +20,22 @@ fun LibraryCoverOnlyGrid(
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) { ) {
LazyLibraryGrid( LazyLibraryGrid(
columns = columns, columns = columns,
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (searchQuery.isNullOrEmpty().not()) {
TextButton(onClick = onGlobalSearchClicked) {
Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!),
modifier = Modifier.zIndex(99f),
)
}
}
}
items( items(
items = items, items = items,
key = { key = {

View file

@ -17,9 +17,11 @@ 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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import eu.kanade.domain.manga.model.MangaCover import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.BadgeGroup import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.selectedBackground import eu.kanade.presentation.util.selectedBackground
import eu.kanade.presentation.util.verticalPadding import eu.kanade.presentation.util.verticalPadding
@ -33,10 +35,23 @@ fun LibraryList(
selection: List<LibraryManga>, selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
) { ) {
LazyColumn( LazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues(), contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) { ) {
item {
if (searchQuery.isNullOrEmpty().not()) {
TextButton(onClick = onGlobalSearchClicked) {
Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!),
modifier = Modifier.zIndex(99f),
)
}
}
}
items( items(
items = items, items = items,
key = { key = {

View file

@ -0,0 +1,96 @@
package eu.kanade.presentation.library.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
@Composable
fun LibraryPager(
state: PagerState,
pageCount: Int,
selectedManga: List<LibraryManga>,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>,
onClickManga: (LibraryManga) -> Unit,
onLongClickManga: (LibraryManga) -> Unit,
) {
HorizontalPager(
count = pageCount,
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,
) { page ->
val library by getLibraryForPage(page)
val displayMode by getDisplayModeForPage(page)
val columns by if (displayMode != DisplayModeSetting.LIST) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
remember(isLandscape) { getColumnsForOrientation(isLandscape) }
} else {
remember { mutableStateOf(0) }
}
when (displayMode) {
DisplayModeSetting.LIST -> {
LibraryList(
items = library,
selection = selectedManga,
onClick = onClickManga,
onLongClick = onLongClickManga,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
)
}
DisplayModeSetting.COMPACT_GRID -> {
LibraryCompactGrid(
items = library,
columns = columns,
selection = selectedManga,
onClick = onClickManga,
onLongClick = onLongClickManga,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
)
}
DisplayModeSetting.COMFORTABLE_GRID -> {
LibraryComfortableGrid(
items = library,
columns = columns,
selection = selectedManga,
onClick = onClickManga,
onLongClick = onLongClickManga,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
)
}
DisplayModeSetting.COVER_ONLY_GRID -> {
LibraryCoverOnlyGrid(
items = library,
columns = columns,
selection = selectedManga,
onClick = onClickManga,
onLongClick = onLongClickManga,
searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked,
)
}
}
}
}

View file

@ -0,0 +1,77 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.pager.PagerState
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.components.DownloadedOnlyModeBanner
import eu.kanade.presentation.components.IncognitoModeBanner
import eu.kanade.presentation.components.Pill
import kotlinx.coroutines.launch
@Composable
fun LibraryTabs(
state: PagerState,
categories: List<Category>,
showMangaCount: Boolean,
isDownloadOnly: Boolean,
isIncognitoMode: Boolean,
getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>,
) {
val scope = rememberCoroutineScope()
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
Column {
ScrollableTabRow(
selectedTabIndex = state.currentPage,
edgePadding = 0.dp,
) {
categories.forEachIndexed { index, category ->
val count by if (showMangaCount) {
getNumberOfMangaForCategory(category.id)
} else {
remember { mutableStateOf<Int?>(null) }
}
Tab(
selected = state.currentPage == index,
onClick = { scope.launch { state.animateScrollToPage(index) } },
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = category.name)
if (count != null) {
Pill(
text = "$count",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 10.sp,
)
}
}
},
)
}
}
if (isDownloadOnly) {
DownloadedOnlyModeBanner()
}
if (isIncognitoMode) {
IncognitoModeBanner()
}
}
}

View file

@ -0,0 +1,188 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.Pill
import eu.kanade.presentation.library.LibraryState
import eu.kanade.presentation.theme.active
import kotlinx.coroutines.delay
@Composable
fun LibraryToolbar(
state: LibraryState,
title: LibraryToolbarTitle,
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
onClickFilter: () -> Unit,
onClickRefresh: () -> Unit,
) = when {
state.searchQuery != null -> LibrarySearchToolbar(
searchQuery = state.searchQuery!!,
onChangeSearchQuery = { state.searchQuery = it },
onClickCloseSearch = { state.searchQuery = null },
)
state.selectionMode -> LibrarySelectionToolbar(
state = state,
onClickUnselectAll = onClickUnselectAll,
onClickSelectAll = onClickSelectAll,
onClickInvertSelection = onClickInvertSelection,
)
else -> LibraryRegularToolbar(
title = title,
hasFilters = state.hasActiveFilters,
onClickSearch = { state.searchQuery = "" },
onClickFilter = onClickFilter,
onClickRefresh = onClickRefresh,
)
}
@Composable
fun LibraryRegularToolbar(
title: LibraryToolbarTitle,
hasFilters: Boolean,
onClickSearch: () -> Unit,
onClickFilter: () -> Unit,
onClickRefresh: () -> Unit,
) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current
SmallTopAppBar(
modifier = Modifier.statusBarsPadding(),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title.text,
maxLines = 1,
modifier = Modifier.weight(1f, false),
overflow = TextOverflow.Ellipsis,
)
if (title.numberOfManga != null) {
Pill(
text = "${title.numberOfManga}",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 14.sp,
)
}
}
},
actions = {
IconButton(onClick = onClickSearch) {
Icon(Icons.Outlined.Search, contentDescription = "search")
}
IconButton(onClick = onClickFilter) {
Icon(Icons.Outlined.FilterList, contentDescription = "search", tint = filterTint)
}
IconButton(onClick = onClickRefresh) {
Icon(Icons.Outlined.Refresh, contentDescription = "search")
}
},
)
}
@Composable
fun LibrarySelectionToolbar(
state: LibraryState,
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
) {
val backgroundColor by TopAppBarDefaults.smallTopAppBarColors().containerColor(1f)
SmallTopAppBar(
modifier = Modifier
.drawBehind {
drawRect(backgroundColor.copy(alpha = 1f))
}
.statusBarsPadding(),
navigationIcon = {
IconButton(onClick = onClickUnselectAll) {
Icon(Icons.Outlined.Close, contentDescription = "close")
}
},
title = {
Text(text = "${state.selection.size}")
},
actions = {
IconButton(onClick = onClickSelectAll) {
Icon(Icons.Outlined.SelectAll, contentDescription = "search")
}
IconButton(onClick = onClickInvertSelection) {
Icon(Icons.Outlined.FlipToBack, contentDescription = "invert")
}
},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
)
}
@Composable
fun LibrarySearchToolbar(
searchQuery: String,
onChangeSearchQuery: (String) -> Unit,
onClickCloseSearch: () -> Unit,
) {
val focusRequester = remember { FocusRequester.Default }
SmallTopAppBar(
modifier = Modifier.statusBarsPadding(),
navigationIcon = {
IconButton(onClick = onClickCloseSearch) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "back")
}
},
title = {
BasicTextField(
value = searchQuery,
onValueChange = onChangeSearchQuery,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground),
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(100)
focusRequester.requestFocus()
}
},
)
}
data class LibraryToolbarTitle(
val text: String,
val numberOfManga: Int? = null,
)

View file

@ -0,0 +1,12 @@
package eu.kanade.presentation.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val ColorScheme.active: Color
@Composable
get() {
return if (isSystemInDarkTheme()) Color(255, 235, 59) else Color(255, 193, 7)
}

View file

@ -1,208 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.library.components.LibraryComfortableGrid
import eu.kanade.presentation.library.components.LibraryCompactGrid
import eu.kanade.presentation.library.components.LibraryCoverOnlyGrid
import eu.kanade.presentation.library.components.LibraryList
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* This adapter stores the categories from the library, used with a ViewPager.
*
* @constructor creates an instance of the adapter.
*/
class LibraryAdapter(
private val presenter: LibraryPresenter,
private val onClickManga: (LibraryManga) -> Unit,
private val preferences: PreferencesHelper = Injekt.get(),
) : RecyclerViewPagerAdapter() {
/**
* The categories to bind in the adapter.
*/
var categories: List<Category> = mutableStateListOf()
private set
/**
* The number of manga in each category.
* List order must be the same as [categories]
*/
private var itemsPerCategory: List<Int> = emptyList()
private var boundViews = arrayListOf<View>()
/**
* Pair of category and size of category
*/
fun updateCategories(new: List<Pair<Category, Int>>) {
var updated = false
val newCategories = new.map { it.first }
if (categories != newCategories) {
categories = newCategories
updated = true
}
val newItemsPerCategory = new.map { it.second }
if (itemsPerCategory !== newItemsPerCategory) {
itemsPerCategory = newItemsPerCategory
updated = true
}
if (updated) {
notifyDataSetChanged()
}
}
/**
* Creates a new view for this adapter.
*
* @return a new view.
*/
override fun inflateView(container: ViewGroup, viewType: Int): View {
val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false)
return binding.root
}
/**
* Binds a view with a position.
*
* @param view the view to bind.
* @param position the position in the adapter.
*/
override fun bindView(view: View, position: Int) {
(view as ComposeView).apply {
setComposeContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val category = presenter.categories[position]
val displayMode = presenter.getDisplayMode(index = position)
val mangaList by presenter.getMangaForCategory(categoryId = category.id)
val onClickManga = { manga: LibraryManga ->
if (presenter.hasSelection().not()) {
onClickManga(manga)
} else {
presenter.toggleSelection(manga)
}
}
val onLongClickManga = { manga: LibraryManga ->
presenter.toggleSelection(manga)
}
SwipeRefresh(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = rememberSwipeRefreshState(isRefreshing = false),
onRefresh = {
if (LibraryUpdateService.start(context, category)) {
context.toast(R.string.updating_category)
}
},
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
when (displayMode) {
DisplayModeSetting.LIST -> {
LibraryList(
items = mangaList,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
DisplayModeSetting.COMPACT_GRID -> {
LibraryCompactGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
DisplayModeSetting.COMFORTABLE_GRID -> {
LibraryComfortableGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
DisplayModeSetting.COVER_ONLY_GRID -> {
LibraryCoverOnlyGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
}
}
}
}
boundViews.add(view)
}
/**
* Recycles a view.
*
* @param view the view to recycle.
* @param position the position in the adapter.
*/
override fun recycleView(view: View, position: Int) {
boundViews.remove(view)
}
/**
* Returns the number of categories.
*
* @return the number of categories or 0 if the list is null.
*/
override fun getCount(): Int {
return categories.size
}
/**
* Returns the title to display for a category.
*
* @param position the position of the element.
* @return the title to display.
*/
override fun getPageTitle(position: Int): CharSequence {
return if (!preferences.categoryNumberOfItems().get()) {
categories[position].name
} else {
categories[position].let { "${it.name} (${itemsPerCategory[position]})" }
}
}
override fun getViewType(position: Int): Int = -1
}

View file

@ -1,240 +1,119 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.compose.runtime.Composable
import androidx.core.view.isVisible import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
import com.fredporciuncula.flow.preferences.Preference
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.toDbCategory import eu.kanade.domain.category.model.toDbCategory
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.library.LibraryScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.ActionModeWithToolbar
import eu.kanade.tachiyomi.widget.EmptyView
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.viewpager.pageSelections
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class LibraryController( class LibraryController(
bundle: Bundle? = null, bundle: Bundle? = null,
private val preferences: PreferencesHelper = Injekt.get(), ) : FullComposeController<LibraryPresenter>(bundle),
) : SearchableNucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
RootController, RootController,
TabbedController,
ActionModeWithToolbar.Callback,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DeleteLibraryMangasDialog.Listener { DeleteLibraryMangasDialog.Listener {
/**
* Position of the active category.
*/
private var activeCategory: Int = preferences.lastUsedCategory().get()
/**
* Action mode for selections.
*/
private var actionMode: ActionModeWithToolbar? = null
private var mangaMap: LibraryMap = emptyMap()
private var adapter: LibraryAdapter? = null
/** /**
* Sheet containing filter/sort/display items. * Sheet containing filter/sort/display items.
*/ */
private var settingsSheet: LibrarySettingsSheet? = null private var settingsSheet: LibrarySettingsSheet? = null
private var tabsVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
private var mangaCountVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
private var tabsVisibilitySubscription: Subscription? = null
private var mangaCountVisibilitySubscription: Subscription? = null
init { init {
setHasOptionsMenu(true)
retainViewMode = RetainViewMode.RETAIN_DETACH retainViewMode = RetainViewMode.RETAIN_DETACH
} }
private var currentTitle: String? = null override fun createPresenter(): LibraryPresenter = LibraryPresenter()
set(value) {
if (field != value) {
field = value
setTitle()
}
}
override fun getTitle(): String? { @Composable
return currentTitle ?: resources?.getString(R.string.label_library) override fun ComposeContent() {
val context = LocalContext.current
LibraryScreen(
presenter = presenter,
onMangaClicked = ::openManga,
onGlobalSearchClicked = {
router.pushController(GlobalSearchController(presenter.query))
},
onChangeCategoryClicked = ::showMangaCategoriesDialog,
onMarkAsReadClicked = { markReadStatus(true) },
onMarkAsUnreadClicked = { markReadStatus(false) },
onDownloadClicked = ::downloadUnreadChapters,
onDeleteClicked = ::showDeleteMangaDialog,
onClickFilter = ::showSettingsSheet,
onClickRefresh = {
if (LibraryUpdateService.start(context)) {
context.toast(R.string.updating_library)
}
},
onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) },
onClickSelectAll = { presenter.selectAll(presenter.activeCategory) },
onClickUnselectAll = ::clearSelection,
)
LaunchedEffect(presenter.selectionMode) {
val activity = (activity as? MainActivity) ?: return@LaunchedEffect
activity.showBottomNav(presenter.selectionMode.not())
}
} }
private fun updateTitle() { override fun handleBack(): Boolean {
val showCategoryTabs = preferences.categoryTabs().get() if (presenter.selection.isNotEmpty()) {
val currentCategory = adapter?.categories?.getOrNull(binding.libraryPager.currentItem) presenter.clearSelection()
return true
var title = if (showCategoryTabs) {
resources?.getString(R.string.label_library)
} else {
currentCategory?.name
} }
return false
if (preferences.categoryNumberOfItems().get()) {
if (!showCategoryTabs || adapter?.categories?.size == 1) {
title += " (${mangaMap[currentCategory?.id]?.size ?: 0})"
}
}
currentTitle = title
} }
override fun createPresenter(): LibraryPresenter {
return LibraryPresenter()
}
override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) { override fun onViewCreated(view: View) {
super.onViewCreated(view) super.onViewCreated(view)
adapter = LibraryAdapter(
presenter = presenter,
onClickManga = {
openManga(it.id!!)
},
)
getColumnsPreferenceForCurrentOrientation()
.asHotFlow { presenter.columns = it }
.launchIn(viewScope)
binding.libraryPager.adapter = adapter
binding.libraryPager.pageSelections()
.drop(1)
.onEach {
preferences.lastUsedCategory().set(it)
activeCategory = it
updateTitle()
}
.launchIn(viewScope)
if (adapter!!.categories.isNotEmpty()) {
createActionModeIfNeeded()
}
settingsSheet = LibrarySettingsSheet(router) { group -> settingsSheet = LibrarySettingsSheet(router) { group ->
when (group) { when (group) {
is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged()
is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged() is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged()
is LibrarySettingsSheet.Display.DisplayGroup -> { is LibrarySettingsSheet.Display.DisplayGroup -> {}
val delay = if (preferences.categorizedDisplaySettings().get()) 125L else 0L
Observable.timer(delay, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe {
reattachAdapter()
}
}
is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged() is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged()
is LibrarySettingsSheet.Display.TabsGroup -> onTabsSettingsChanged() is LibrarySettingsSheet.Display.TabsGroup -> {} // onTabsSettingsChanged()
} }
} }
binding.btnGlobalSearch.clicks()
.onEach {
router.pushController(GlobalSearchController(presenter.query))
}
.launchIn(viewScope)
}
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
} }
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type) super.onChangeStarted(handler, type)
if (type.isEnter) { if (type.isEnter) {
(activity as? MainActivity)?.binding?.tabs?.setupWithViewPager(binding.libraryPager)
presenter.subscribeLibrary() presenter.subscribeLibrary()
} }
} }
override fun onDestroyView(view: View) { override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
adapter = null
settingsSheet?.sheetScope?.cancel() settingsSheet?.sheetScope?.cancel()
settingsSheet = null settingsSheet = null
tabsVisibilitySubscription?.unsubscribe()
tabsVisibilitySubscription = null
super.onDestroyView(view) super.onDestroyView(view)
} }
override fun configureTabs(tabs: TabLayout): Boolean {
with(tabs) {
isVisible = false
tabGravity = TabLayout.GRAVITY_START
tabMode = TabLayout.MODE_SCROLLABLE
}
tabsVisibilitySubscription?.unsubscribe()
tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible ->
tabs.isVisible = visible
}
mangaCountVisibilitySubscription?.unsubscribe()
mangaCountVisibilitySubscription = mangaCountVisibilityRelay.subscribe {
adapter?.notifyDataSetChanged()
}
return false
}
override fun cleanupTabs(tabs: TabLayout) {
tabsVisibilitySubscription?.unsubscribe()
tabsVisibilitySubscription = null
}
fun showSettingsSheet() { fun showSettingsSheet() {
if (adapter?.categories?.isNotEmpty() == true) { if (presenter.categories.isNotEmpty()) {
adapter?.categories?.get(binding.libraryPager.currentItem)?.let { category -> presenter.categories[presenter.activeCategory].let { category ->
settingsSheet?.show(category.toDbCategory()) settingsSheet?.show(category.toDbCategory())
} }
} else { } else {
@ -242,61 +121,6 @@ class LibraryController(
} }
} }
fun onNextLibraryUpdate(categories: List<Category>, mangaMap: LibraryMap) {
val view = view ?: return
val adapter = adapter ?: return
// Show empty view if needed
if (mangaMap.isNotEmpty()) {
binding.emptyView.hide()
} else {
binding.emptyView.show(
R.string.information_empty_library,
listOf(
EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) {
activity?.openInBrowser("https://tachiyomi.org/help/guides/getting-started")
},
),
)
(activity as? MainActivity)?.ready = true
}
// Get the current active category.
val activeCat = if (adapter.categories.isNotEmpty()) {
binding.libraryPager.currentItem
} else {
activeCategory
}
// Set the categories
adapter.updateCategories(categories.map { it to (mangaMap[it.id]?.size ?: 0) })
// Restore active category.
binding.libraryPager.setCurrentItem(activeCat, false)
// Trigger display of tabs
onTabsSettingsChanged(firstLaunch = true)
// Delay the scroll position to allow the view to be properly measured.
view.post {
if (isAttached) {
(activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true)
}
}
presenter.loadedManga.clear()
mangaMap.forEach {
presenter.loadedManga[it.key] = it.value
}
presenter.loadedMangaFlow.value = presenter.loadedManga
// Send the manga map to child fragments after the adapter is updated.
this.mangaMap = mangaMap
// Finally update the title
updateTitle()
}
private fun onFilterChanged() { private fun onFilterChanged() {
presenter.requestFilterUpdate() presenter.requestFilterUpdate()
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
@ -306,146 +130,17 @@ class LibraryController(
presenter.requestBadgesUpdate() presenter.requestBadgesUpdate()
} }
private fun onTabsSettingsChanged(firstLaunch: Boolean = false) {
if (!firstLaunch) {
mangaCountVisibilityRelay.call(preferences.categoryNumberOfItems().get())
}
tabsVisibilityRelay.call(preferences.categoryTabs().get() && (adapter?.categories?.size ?: 0) > 1)
updateTitle()
}
private fun onSortChanged() { private fun onSortChanged() {
presenter.requestSortUpdate() presenter.requestSortUpdate()
} }
/**
* Reattaches the adapter to the view pager to recreate fragments
*/
private fun reattachAdapter() {
val adapter = adapter ?: return
val position = binding.libraryPager.currentItem
adapter.recycle = false
binding.libraryPager.adapter = adapter
binding.libraryPager.currentItem = position
adapter.recycle = true
}
fun createActionModeIfNeeded() {
val activity = activity
if (actionMode == null && activity is MainActivity) {
actionMode = activity.startActionModeAndToolbar(this)
activity.showBottomNav(false)
}
}
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search)
// Mutate the filter icon because it needs to be tinted and the resource is shared.
menu.findItem(R.id.action_filter).icon?.mutate()
}
fun search(query: String) { fun search(query: String) {
presenter.query = query presenter.searchQuery = query
}
private fun performSearch() {
if (presenter.query.isNotEmpty()) {
binding.btnGlobalSearch.isVisible = true
binding.btnGlobalSearch.text =
resources?.getString(R.string.action_global_search_query, presenter.query)
} else {
binding.btnGlobalSearch.isVisible = false
}
} }
override fun onPrepareOptionsMenu(menu: Menu) { override fun onPrepareOptionsMenu(menu: Menu) {
val settingsSheet = settingsSheet ?: return val settingsSheet = settingsSheet ?: return
presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters()
val filterItem = menu.findItem(R.id.action_filter)
// Tint icon if there's a filter active
if (settingsSheet.filters.hasActiveFilters()) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
filterItem.icon?.setTint(filterColor)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_search -> expandActionViewFromInteraction = true
R.id.action_filter -> showSettingsSheet()
R.id.action_update_library -> {
activity?.let {
if (LibraryUpdateService.start(it)) {
it.toast(R.string.updating_library)
}
}
}
}
return super.onOptionsItemSelected(item)
}
/**
* Invalidates the action mode, forcing it to refresh its content.
*/
fun invalidateActionMode() {
actionMode?.invalidate()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
return true
}
override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) {
menuInflater.inflate(R.menu.library_selection, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = presenter.selection.size
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = count.toString()
}
return true
}
override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
if (presenter.hasSelection().not()) return
toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible =
presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } }
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_move_to_category -> showMangaCategoriesDialog()
R.id.action_download_unread -> downloadUnreadChapters()
R.id.action_mark_as_read -> markReadStatus(true)
R.id.action_mark_as_unread -> markReadStatus(false)
R.id.action_delete -> showDeleteMangaDialog()
R.id.action_select_all -> selectAllCategoryManga()
R.id.action_select_inverse -> selectInverseCategoryManga()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
// Clear all the manga selections and notify child views.
presenter.clearSelection()
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
} }
private fun openManga(mangaId: Long) { private fun openManga(mangaId: Long) {
@ -461,7 +156,6 @@ class LibraryController(
*/ */
fun clearSelection() { fun clearSelection() {
presenter.clearSelection() presenter.clearSelection()
invalidateActionMode()
} }
/** /**
@ -496,13 +190,13 @@ class LibraryController(
private fun downloadUnreadChapters() { private fun downloadUnreadChapters() {
val mangas = presenter.selection.toList() val mangas = presenter.selection.toList()
presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() }) presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
destroyActionModeIfNeeded() presenter.clearSelection()
} }
private fun markReadStatus(read: Boolean) { private fun markReadStatus(read: Boolean) {
val mangas = presenter.selection.toList() val mangas = presenter.selection.toList()
presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read) presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
destroyActionModeIfNeeded() presenter.clearSelection()
} }
private fun showDeleteMangaDialog() { private fun showDeleteMangaDialog() {
@ -512,28 +206,11 @@ class LibraryController(
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) { override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
presenter.setMangaCategories(mangas, addCategories, removeCategories) presenter.setMangaCategories(mangas, addCategories, removeCategories)
destroyActionModeIfNeeded() presenter.clearSelection()
} }
override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) { override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters) presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters)
destroyActionModeIfNeeded() presenter.clearSelection()
}
private fun selectAllCategoryManga() {
presenter.selectAll(binding.libraryPager.currentItem)
}
private fun selectInverseCategoryManga() {
presenter.invertSelection(binding.libraryPager.currentItem)
}
override fun onSearchViewQueryTextChange(newText: String?) {
// Ignore events if this controller isn't at the top to avoid query being reset
if (router.backstack.lastOrNull()?.controller == this) {
presenter.query = newText ?: ""
presenter.searchQuery = newText ?: ""
performSearch()
}
} }
} }

View file

@ -4,13 +4,15 @@ import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember 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 com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.core.prefs.PreferenceMutableState
import eu.kanade.core.util.asFlow
import eu.kanade.core.util.asObservable import eu.kanade.core.util.asObservable
import eu.kanade.data.DatabaseHandler import eu.kanade.data.DatabaseHandler
import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.GetCategories
@ -25,6 +27,10 @@ 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.GetTracks import eu.kanade.domain.track.interactor.GetTracks
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.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.data.database.models.toDomainManga
@ -39,14 +45,16 @@ import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.util.lang.combineLatest import eu.kanade.tachiyomi.util.lang.combineLatest
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
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.State
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import rx.Observable import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -70,6 +78,7 @@ typealias LibraryMap = Map<Long, List<LibraryItem>>
* Presenter of [LibraryController]. * Presenter of [LibraryController].
*/ */
class LibraryPresenter( class LibraryPresenter(
private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
private val handler: DatabaseHandler = Injekt.get(), private val handler: DatabaseHandler = Injekt.get(),
private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getLibraryManga: GetLibraryManga = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(), private val getTracks: GetTracks = Injekt.get(),
@ -83,31 +92,27 @@ class LibraryPresenter(
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
) : BasePresenter<LibraryController>() { ) : BasePresenter<LibraryController>(), LibraryState by state {
private val context = preferences.context private val context = preferences.context
/** var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>())
* Categories of the library.
*/
var categories: List<Category> = mutableStateListOf()
private set private set
var loadedManga = mutableStateMapOf<Long, List<LibraryItem>>()
private set
val loadedMangaFlow = MutableStateFlow(loadedManga)
var searchQuery by mutableStateOf(query)
val selection: MutableList<LibraryManga> = mutableStateListOf()
val isPerCategory by preferences.categorizedDisplaySettings().asState() val isPerCategory by preferences.categorizedDisplaySettings().asState()
var columns by mutableStateOf(0)
var currentDisplayMode by preferences.libraryDisplayMode().asState() var currentDisplayMode by preferences.libraryDisplayMode().asState()
val tabVisibility by preferences.categoryTabs().asState()
val mangaCountVisibility by preferences.categoryNumberOfItems().asState()
var activeCategory: Int by preferences.lastUsedCategory().asState()
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
/** /**
* Relay used to apply the UI filters to the last emission of the library. * Relay used to apply the UI filters to the last emission of the library.
*/ */
@ -123,7 +128,7 @@ class LibraryPresenter(
*/ */
private val sortTriggerRelay = BehaviorRelay.create(Unit) private val sortTriggerRelay = BehaviorRelay.create(Unit)
private var librarySubscription: Subscription? = null private var librarySubscription: Job? = null
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -135,22 +140,31 @@ class LibraryPresenter(
* Subscribes to library if needed. * Subscribes to library if needed.
*/ */
fun subscribeLibrary() { fun subscribeLibrary() {
// TODO: Move this to a coroutine world /**
if (librarySubscription.isNullOrUnsubscribed()) { * TODO: Move this to a coroutine world
librarySubscription = getLibraryObservable() * - 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
.combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> * - Create new db view and new query to just fetch the current category save as needed to instance variable
lib.apply { setBadges(mangaMap) } * - Fetch badges to maps and retrive as needed instead of fetching all of them at once
} */
.combineLatest(getFilterObservable()) { lib, tracks -> if (librarySubscription == null || librarySubscription!!.isCancelled) {
lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks)) librarySubscription = presenterScope.launchIO {
} getLibraryObservable()
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap)) lib.apply { setBadges(mangaMap) }
} }
.observeOn(AndroidSchedulers.mainThread()) .combineLatest(getFilterObservable()) { lib, tracks ->
.subscribeLatestCache({ view, (categories, mangaMap) -> lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks))
view.onNextLibraryUpdate(categories, mangaMap) }
},) .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
}
.observeOn(AndroidSchedulers.mainThread())
.asFlow()
.collectLatest {
state.isLoading = false
loadedManga = it.mangaMap
}
}
} }
} }
@ -397,7 +411,7 @@ class LibraryPresenter(
* @return an observable of the categories and its manga. * @return an observable of the categories and its manga.
*/ */
private fun getLibraryObservable(): Observable<Library> { private fun getLibraryObservable(): Observable<Library> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> return combine(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0)) { val categories = if (libraryManga.containsKey(0)) {
arrayListOf(Category.default(context)) + dbCategories arrayListOf(Category.default(context)) + dbCategories
} else { } else {
@ -411,9 +425,9 @@ class LibraryPresenter(
} }
} }
this.categories = categories state.categories = categories
Library(categories, libraryManga) Library(categories, libraryManga)
} }.asObservable()
} }
/** /**
@ -421,8 +435,8 @@ class LibraryPresenter(
* *
* @return an observable of the categories. * @return an observable of the categories.
*/ */
private fun getCategoriesObservable(): Observable<List<Category>> { private fun getCategoriesObservable(): Flow<List<Category>> {
return getCategories.subscribe().asObservable() return getCategories.subscribe()
} }
/** /**
@ -431,8 +445,8 @@ class LibraryPresenter(
* @return an observable containing a map with the category id as key and a list of manga as the * @return an observable containing a map with the category id as key and a list of manga as the
* value. * value.
*/ */
private fun getLibraryMangasObservable(): Observable<LibraryMap> { private fun getLibraryMangasObservable(): Flow<LibraryMap> {
return getLibraryManga.subscribe().asObservable() return getLibraryManga.subscribe()
.map { list -> .map { list ->
list.map { libraryManga -> list.map { libraryManga ->
// 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
@ -447,7 +461,8 @@ class LibraryPresenter(
* @return an observable of tracked manga. * @return an observable of tracked manga.
*/ */
private fun getFilterObservable(): Observable<Map<Long, Map<Long, Boolean>>> { private fun getFilterObservable(): Observable<Map<Long, Map<Long, Boolean>>> {
return getTracksObservable().combineLatest(filterTriggerRelay.observeOn(Schedulers.io())) { tracks, _ -> tracks } return filterTriggerRelay.observeOn(Schedulers.io())
.combineLatest(getTracksObservable()) { _, tracks -> tracks }
} }
/** /**
@ -458,7 +473,7 @@ class LibraryPresenter(
private fun getTracksObservable(): Observable<Map<Long, Map<Long, Boolean>>> { private fun getTracksObservable(): Observable<Map<Long, Map<Long, Boolean>>> {
// TODO: Move this to domain/data layer // TODO: Move this to domain/data layer
return getTracks.subscribe() return getTracks.subscribe()
.asObservable().map { tracks -> .map { tracks ->
tracks tracks
.groupBy { it.mangaId } .groupBy { it.mangaId }
.mapValues { tracksForMangaId -> .mapValues { tracksForMangaId ->
@ -468,6 +483,7 @@ class LibraryPresenter(
} }
} }
} }
.asObservable()
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
} }
@ -497,7 +513,7 @@ class LibraryPresenter(
*/ */
fun onOpenManga() { fun onOpenManga() {
// Avoid further db updates for the library when it's not needed // Avoid further db updates for the library when it's not needed
librarySubscription?.let { remove(it) } librarySubscription?.cancel()
} }
/** /**
@ -610,14 +626,50 @@ class LibraryPresenter(
} }
@Composable @Composable
fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State<List<LibraryItem>> { 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) preferences.landscapeColumns() else preferences.portraitColumns()).asState()
}
// 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(id = R.string.label_library)
val default = remember { LibraryToolbarTitle(defaultTitle) }
return produceState(initialValue = default, category, mangaCountVisibility, tabVisibility) {
val title = if (tabVisibility.not()) category?.name ?: defaultTitle else defaultTitle
value = when {
category == null -> default
(tabVisibility.not() && mangaCountVisibility.not()) -> LibraryToolbarTitle(title)
tabVisibility.not() && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size)
(tabVisibility && categories.size > 1) && mangaCountVisibility -> LibraryToolbarTitle(title)
tabVisibility && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size)
else -> default
}
}
}
@Composable
fun getMangaForCategory(page: Int): androidx.compose.runtime.State<List<LibraryItem>> {
val categoryId = remember(categories) {
categories.getOrNull(page)?.id ?: -1
}
val unfiltered = loadedManga[categoryId] ?: emptyList() val unfiltered = loadedManga[categoryId] ?: emptyList()
return derivedStateOf { return derivedStateOf {
val query = searchQuery val query = searchQuery
if (query.isNotBlank()) { if (query.isNullOrBlank().not()) {
unfiltered.filter { unfiltered.filter {
it.filter(query) it.filter(query!!)
} }
} else { } else {
unfiltered unfiltered
@ -626,9 +678,9 @@ class LibraryPresenter(
} }
@Composable @Composable
fun getDisplayMode(index: Int): DisplayModeSetting { fun getDisplayMode(index: Int): androidx.compose.runtime.State<DisplayModeSetting> {
val category = categories[index] val category = categories[index]
return remember { return derivedStateOf {
if (isPerCategory.not() || category.id == 0L) { if (isPerCategory.not() || category.id == 0L) {
currentDisplayMode currentDisplayMode
} else { } else {
@ -642,34 +694,30 @@ class LibraryPresenter(
} }
fun clearSelection() { fun clearSelection() {
selection.clear() state.selection = emptyList()
} }
fun toggleSelection(manga: LibraryManga) { fun toggleSelection(manga: LibraryManga) {
val mutableList = state.selection.toMutableList()
if (selection.fastAny { it.id == manga.id }) { if (selection.fastAny { it.id == manga.id }) {
selection.remove(manga) mutableList.remove(manga)
} else { } else {
selection.add(manga) mutableList.add(manga)
} }
view?.invalidateActionMode() state.selection = mutableList
view?.createActionModeIfNeeded()
} }
fun selectAll(index: Int) { fun selectAll(index: Int) {
val category = categories[index] val category = categories[index]
val items = loadedManga[category.id] ?: emptyList() val items = loadedManga[category.id] ?: emptyList()
selection.addAll(items.filterNot { it.manga in selection }.map { it.manga }) state.selection = state.selection.toMutableList().apply {
view?.createActionModeIfNeeded() addAll(items.filterNot { it.manga in selection }.map { it.manga })
view?.invalidateActionMode() }
} }
fun invertSelection(index: Int) { fun invertSelection(index: Int) {
val category = categories[index] val category = categories[index]
val items = (loadedManga[category.id] ?: emptyList()).map { it.manga } val items = (loadedManga[category.id] ?: emptyList()).map { it.manga }
val invert = items.filterNot { it in selection } state.selection = items.filterNot { it in selection }
selection.removeAll(items)
selection.addAll(invert)
view?.createActionModeIfNeeded()
view?.invalidateActionMode()
} }
} }

View file

@ -488,9 +488,13 @@ class MainActivity : BaseActivity() {
return return
} }
val backstackSize = router.backstackSize val backstackSize = router.backstackSize
if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { val startScreen = router.getControllerWithTag("$startScreenId")
if (backstackSize == 1 && startScreen == null) {
// Return to start screen // Return to start screen
moveToStartScreen() moveToStartScreen()
setSelectedNavItem(startScreenId)
} else if (startScreen != null && router.handleBack()) {
// Clear selection for Library screen
} else if (shouldHandleExitConfirmation()) { } else if (shouldHandleExitConfirmation()) {
// Exit confirmation (resets after 2 seconds) // Exit confirmation (resets after 2 seconds)
lifecycleScope.launchUI { resetExitConfirmation() } lifecycleScope.launchUI { resetExitConfirmation() }

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/btn_global_search"
style="?attr/borderlessButtonStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:visibility="gone"
tools:text="Search"
tools:visibility="visible" />
<androidx.viewpager.widget.ViewPager
android:id="@+id/library_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>

View file

@ -19,4 +19,6 @@ material-icons = { module = "androidx.compose.material:material-icons-extended",
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" } accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" }
accompanist-pager-core = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist"}
accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist"}