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:
parent
e8b7743826
commit
2b8d1bcc02
21 changed files with 973 additions and 687 deletions
|
@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
38
app/src/main/java/eu/kanade/presentation/components/Pill.kt
Normal file
38
app/src/main/java/eu/kanade/presentation/components/Pill.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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 = {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 = {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
12
app/src/main/java/eu/kanade/presentation/theme/Color.kt
Normal file
12
app/src/main/java/eu/kanade/presentation/theme/Color.kt
Normal 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)
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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) {
|
@Composable
|
||||||
field = value
|
override fun ComposeContent() {
|
||||||
setTitle()
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTitle(): String? {
|
override fun handleBack(): Boolean {
|
||||||
return currentTitle ?: resources?.getString(R.string.label_library)
|
if (presenter.selection.isNotEmpty()) {
|
||||||
|
presenter.clearSelection()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
private fun updateTitle() {
|
|
||||||
val showCategoryTabs = preferences.categoryTabs().get()
|
|
||||||
val currentCategory = adapter?.categories?.getOrNull(binding.libraryPager.currentItem)
|
|
||||||
|
|
||||||
var title = if (showCategoryTabs) {
|
|
||||||
resources?.getString(R.string.label_library)
|
|
||||||
} else {
|
|
||||||
currentCategory?.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 +140,15 @@ 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
|
||||||
|
* - Create new db view and new query to just fetch the current category save as needed to instance variable
|
||||||
|
* - Fetch badges to maps and retrive as needed instead of fetching all of them at once
|
||||||
|
*/
|
||||||
|
if (librarySubscription == null || librarySubscription!!.isCancelled) {
|
||||||
|
librarySubscription = presenterScope.launchIO {
|
||||||
|
getLibraryObservable()
|
||||||
.combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
|
.combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
|
||||||
lib.apply { setBadges(mangaMap) }
|
lib.apply { setBadges(mangaMap) }
|
||||||
}
|
}
|
||||||
|
@ -148,9 +159,12 @@ class LibraryPresenter(
|
||||||
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
|
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
|
||||||
}
|
}
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribeLatestCache({ view, (categories, mangaMap) ->
|
.asFlow()
|
||||||
view.onNextLibraryUpdate(categories, mangaMap)
|
.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() }
|
||||||
|
|
|
@ -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>
|
|
|
@ -20,3 +20,5 @@ 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"}
|
||||||
|
|
Reference in a new issue