mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 07:41:51 +01:00
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
@ -161,6 +161,8 @@ dependencies {
|
||||
implementation(compose.accompanist.webview)
|
||||
implementation(compose.accompanist.swiperefresh)
|
||||
implementation(compose.accompanist.flowlayout)
|
||||
implementation(compose.accompanist.pager.core)
|
||||
implementation(compose.accompanist.pager.indicators)
|
||||
|
||||
implementation(androidx.paging.runtime)
|
||||
implementation(androidx.paging.compose)
|
||||
@ -302,6 +304,7 @@ tasks {
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
"-opt-in=com.google.accompanist.pager.ExperimentalPagerApi"
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -38,8 +38,8 @@ fun Badge(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color)
|
||||
.clip(shape),
|
||||
.clip(shape)
|
||||
.background(color),
|
||||
) {
|
||||
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.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
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.ui.library.LibraryItem
|
||||
|
||||
@ -21,10 +26,22 @@ fun LibraryComfortableGrid(
|
||||
selection: List<LibraryManga>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
searchQuery: String?,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
) {
|
||||
LazyLibraryGrid(
|
||||
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,
|
||||
key = {
|
||||
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ui.library.LibraryItem
|
||||
|
||||
@ -29,10 +34,23 @@ fun LibraryCompactGrid(
|
||||
selection: List<LibraryManga>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
searchQuery: String?,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
) {
|
||||
LazyLibraryGrid(
|
||||
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,
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ui.library.LibraryItem
|
||||
|
||||
@ -14,10 +20,22 @@ fun LibraryCoverOnlyGrid(
|
||||
selection: List<LibraryManga>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
searchQuery: String?,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
) {
|
||||
LazyLibraryGrid(
|
||||
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,
|
||||
key = {
|
||||
|
@ -17,9 +17,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import eu.kanade.domain.manga.model.MangaCover
|
||||
import eu.kanade.presentation.components.Badge
|
||||
import eu.kanade.presentation.components.BadgeGroup
|
||||
import eu.kanade.presentation.components.TextButton
|
||||
import eu.kanade.presentation.util.horizontalPadding
|
||||
import eu.kanade.presentation.util.selectedBackground
|
||||
import eu.kanade.presentation.util.verticalPadding
|
||||
@ -33,10 +35,23 @@ fun LibraryList(
|
||||
selection: List<LibraryManga>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
searchQuery: String?,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
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,
|
||||
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
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bluelinelabs.conductor.ControllerChangeHandler
|
||||
import com.bluelinelabs.conductor.ControllerChangeType
|
||||
import 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.toDbCategory
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.toDbManga
|
||||
import eu.kanade.presentation.library.LibraryScreen
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.toDomainManga
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
|
||||
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
|
||||
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.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
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.widget.ActionModeWithToolbar
|
||||
import eu.kanade.tachiyomi.widget.EmptyView
|
||||
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
|
||||
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(
|
||||
bundle: Bundle? = null,
|
||||
private val preferences: PreferencesHelper = Injekt.get(),
|
||||
) : SearchableNucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
|
||||
) : FullComposeController<LibraryPresenter>(bundle),
|
||||
RootController,
|
||||
TabbedController,
|
||||
ActionModeWithToolbar.Callback,
|
||||
ChangeMangaCategoriesDialog.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.
|
||||
*/
|
||||
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 {
|
||||
setHasOptionsMenu(true)
|
||||
retainViewMode = RetainViewMode.RETAIN_DETACH
|
||||
}
|
||||
|
||||
private var currentTitle: String? = null
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
setTitle()
|
||||
}
|
||||
}
|
||||
override fun createPresenter(): LibraryPresenter = LibraryPresenter()
|
||||
|
||||
override fun getTitle(): String? {
|
||||
return currentTitle ?: resources?.getString(R.string.label_library)
|
||||
@Composable
|
||||
override fun ComposeContent() {
|
||||
val context = LocalContext.current
|
||||
LibraryScreen(
|
||||
presenter = presenter,
|
||||
onMangaClicked = ::openManga,
|
||||
onGlobalSearchClicked = {
|
||||
router.pushController(GlobalSearchController(presenter.query))
|
||||
},
|
||||
onChangeCategoryClicked = ::showMangaCategoriesDialog,
|
||||
onMarkAsReadClicked = { markReadStatus(true) },
|
||||
onMarkAsUnreadClicked = { markReadStatus(false) },
|
||||
onDownloadClicked = ::downloadUnreadChapters,
|
||||
onDeleteClicked = ::showDeleteMangaDialog,
|
||||
onClickFilter = ::showSettingsSheet,
|
||||
onClickRefresh = {
|
||||
if (LibraryUpdateService.start(context)) {
|
||||
context.toast(R.string.updating_library)
|
||||
}
|
||||
},
|
||||
onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) },
|
||||
onClickSelectAll = { presenter.selectAll(presenter.activeCategory) },
|
||||
onClickUnselectAll = ::clearSelection,
|
||||
)
|
||||
LaunchedEffect(presenter.selectionMode) {
|
||||
val activity = (activity as? MainActivity) ?: return@LaunchedEffect
|
||||
activity.showBottomNav(presenter.selectionMode.not())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTitle() {
|
||||
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
|
||||
override fun handleBack(): Boolean {
|
||||
if (presenter.selection.isNotEmpty()) {
|
||||
presenter.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
if (preferences.categoryNumberOfItems().get()) {
|
||||
if (!showCategoryTabs || adapter?.categories?.size == 1) {
|
||||
title += " (${mangaMap[currentCategory?.id]?.size ?: 0})"
|
||||
}
|
||||
}
|
||||
|
||||
currentTitle = title
|
||||
return false
|
||||
}
|
||||
|
||||
override fun createPresenter(): LibraryPresenter {
|
||||
return LibraryPresenter()
|
||||
}
|
||||
|
||||
override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: 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 ->
|
||||
when (group) {
|
||||
is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged()
|
||||
is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged()
|
||||
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.DisplayGroup -> {}
|
||||
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) {
|
||||
super.onChangeStarted(handler, type)
|
||||
if (type.isEnter) {
|
||||
(activity as? MainActivity)?.binding?.tabs?.setupWithViewPager(binding.libraryPager)
|
||||
presenter.subscribeLibrary()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
destroyActionModeIfNeeded()
|
||||
adapter = null
|
||||
settingsSheet?.sheetScope?.cancel()
|
||||
settingsSheet = null
|
||||
tabsVisibilitySubscription?.unsubscribe()
|
||||
tabsVisibilitySubscription = null
|
||||
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() {
|
||||
if (adapter?.categories?.isNotEmpty() == true) {
|
||||
adapter?.categories?.get(binding.libraryPager.currentItem)?.let { category ->
|
||||
if (presenter.categories.isNotEmpty()) {
|
||||
presenter.categories[presenter.activeCategory].let { category ->
|
||||
settingsSheet?.show(category.toDbCategory())
|
||||
}
|
||||
} 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() {
|
||||
presenter.requestFilterUpdate()
|
||||
activity?.invalidateOptionsMenu()
|
||||
@ -306,146 +130,17 @@ class LibraryController(
|
||||
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() {
|
||||
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) {
|
||||
presenter.query = 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
|
||||
}
|
||||
presenter.searchQuery = query
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
val settingsSheet = settingsSheet ?: return
|
||||
|
||||
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
|
||||
presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters()
|
||||
}
|
||||
|
||||
private fun openManga(mangaId: Long) {
|
||||
@ -461,7 +156,6 @@ class LibraryController(
|
||||
*/
|
||||
fun clearSelection() {
|
||||
presenter.clearSelection()
|
||||
invalidateActionMode()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -496,13 +190,13 @@ class LibraryController(
|
||||
private fun downloadUnreadChapters() {
|
||||
val mangas = presenter.selection.toList()
|
||||
presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
|
||||
destroyActionModeIfNeeded()
|
||||
presenter.clearSelection()
|
||||
}
|
||||
|
||||
private fun markReadStatus(read: Boolean) {
|
||||
val mangas = presenter.selection.toList()
|
||||
presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
|
||||
destroyActionModeIfNeeded()
|
||||
presenter.clearSelection()
|
||||
}
|
||||
|
||||
private fun showDeleteMangaDialog() {
|
||||
@ -512,28 +206,11 @@ class LibraryController(
|
||||
|
||||
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
|
||||
presenter.setMangaCategories(mangas, addCategories, removeCategories)
|
||||
destroyActionModeIfNeeded()
|
||||
presenter.clearSelection()
|
||||
}
|
||||
|
||||
override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||
presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters)
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
presenter.clearSelection()
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,15 @@ import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import 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.data.DatabaseHandler
|
||||
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.isLocal
|
||||
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.database.models.LibraryManga
|
||||
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.SortModeSetting
|
||||
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.removeCovers
|
||||
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 rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@ -70,6 +78,7 @@ typealias LibraryMap = Map<Long, List<LibraryItem>>
|
||||
* Presenter of [LibraryController].
|
||||
*/
|
||||
class LibraryPresenter(
|
||||
private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
|
||||
private val handler: DatabaseHandler = Injekt.get(),
|
||||
private val getLibraryManga: GetLibraryManga = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
@ -83,31 +92,27 @@ class LibraryPresenter(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val trackManager: TrackManager = Injekt.get(),
|
||||
) : BasePresenter<LibraryController>() {
|
||||
) : BasePresenter<LibraryController>(), LibraryState by state {
|
||||
|
||||
private val context = preferences.context
|
||||
|
||||
/**
|
||||
* Categories of the library.
|
||||
*/
|
||||
var categories: List<Category> = mutableStateListOf()
|
||||
var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>())
|
||||
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()
|
||||
|
||||
var columns by mutableStateOf(0)
|
||||
|
||||
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.
|
||||
*/
|
||||
@ -123,7 +128,7 @@ class LibraryPresenter(
|
||||
*/
|
||||
private val sortTriggerRelay = BehaviorRelay.create(Unit)
|
||||
|
||||
private var librarySubscription: Subscription? = null
|
||||
private var librarySubscription: Job? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
@ -135,22 +140,31 @@ class LibraryPresenter(
|
||||
* Subscribes to library if needed.
|
||||
*/
|
||||
fun subscribeLibrary() {
|
||||
// TODO: Move this to a coroutine world
|
||||
if (librarySubscription.isNullOrUnsubscribed()) {
|
||||
librarySubscription = getLibraryObservable()
|
||||
.combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
|
||||
lib.apply { setBadges(mangaMap) }
|
||||
}
|
||||
.combineLatest(getFilterObservable()) { lib, tracks ->
|
||||
lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks))
|
||||
}
|
||||
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
|
||||
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeLatestCache({ view, (categories, mangaMap) ->
|
||||
view.onNextLibraryUpdate(categories, mangaMap)
|
||||
},)
|
||||
/**
|
||||
* TODO: Move this to a coroutine world
|
||||
* - 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, _ ->
|
||||
lib.apply { setBadges(mangaMap) }
|
||||
}
|
||||
.combineLatest(getFilterObservable()) { lib, tracks ->
|
||||
lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks))
|
||||
}
|
||||
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
|
||||
lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.asFlow()
|
||||
.collectLatest {
|
||||
state.isLoading = false
|
||||
loadedManga = it.mangaMap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,7 +411,7 @@ class LibraryPresenter(
|
||||
* @return an observable of the categories and its manga.
|
||||
*/
|
||||
private fun getLibraryObservable(): Observable<Library> {
|
||||
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga ->
|
||||
return combine(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga ->
|
||||
val categories = if (libraryManga.containsKey(0)) {
|
||||
arrayListOf(Category.default(context)) + dbCategories
|
||||
} else {
|
||||
@ -411,9 +425,9 @@ class LibraryPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
this.categories = categories
|
||||
state.categories = categories
|
||||
Library(categories, libraryManga)
|
||||
}
|
||||
}.asObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -421,8 +435,8 @@ class LibraryPresenter(
|
||||
*
|
||||
* @return an observable of the categories.
|
||||
*/
|
||||
private fun getCategoriesObservable(): Observable<List<Category>> {
|
||||
return getCategories.subscribe().asObservable()
|
||||
private fun getCategoriesObservable(): Flow<List<Category>> {
|
||||
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
|
||||
* value.
|
||||
*/
|
||||
private fun getLibraryMangasObservable(): Observable<LibraryMap> {
|
||||
return getLibraryManga.subscribe().asObservable()
|
||||
private fun getLibraryMangasObservable(): Flow<LibraryMap> {
|
||||
return getLibraryManga.subscribe()
|
||||
.map { list ->
|
||||
list.map { libraryManga ->
|
||||
// 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.
|
||||
*/
|
||||
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>>> {
|
||||
// TODO: Move this to domain/data layer
|
||||
return getTracks.subscribe()
|
||||
.asObservable().map { tracks ->
|
||||
.map { tracks ->
|
||||
tracks
|
||||
.groupBy { it.mangaId }
|
||||
.mapValues { tracksForMangaId ->
|
||||
@ -468,6 +483,7 @@ class LibraryPresenter(
|
||||
}
|
||||
}
|
||||
}
|
||||
.asObservable()
|
||||
.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@ -497,7 +513,7 @@ class LibraryPresenter(
|
||||
*/
|
||||
fun onOpenManga() {
|
||||
// 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
|
||||
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()
|
||||
|
||||
return derivedStateOf {
|
||||
val query = searchQuery
|
||||
if (query.isNotBlank()) {
|
||||
if (query.isNullOrBlank().not()) {
|
||||
unfiltered.filter {
|
||||
it.filter(query)
|
||||
it.filter(query!!)
|
||||
}
|
||||
} else {
|
||||
unfiltered
|
||||
@ -626,9 +678,9 @@ class LibraryPresenter(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getDisplayMode(index: Int): DisplayModeSetting {
|
||||
fun getDisplayMode(index: Int): androidx.compose.runtime.State<DisplayModeSetting> {
|
||||
val category = categories[index]
|
||||
return remember {
|
||||
return derivedStateOf {
|
||||
if (isPerCategory.not() || category.id == 0L) {
|
||||
currentDisplayMode
|
||||
} else {
|
||||
@ -642,34 +694,30 @@ class LibraryPresenter(
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selection.clear()
|
||||
state.selection = emptyList()
|
||||
}
|
||||
|
||||
fun toggleSelection(manga: LibraryManga) {
|
||||
val mutableList = state.selection.toMutableList()
|
||||
if (selection.fastAny { it.id == manga.id }) {
|
||||
selection.remove(manga)
|
||||
mutableList.remove(manga)
|
||||
} else {
|
||||
selection.add(manga)
|
||||
mutableList.add(manga)
|
||||
}
|
||||
view?.invalidateActionMode()
|
||||
view?.createActionModeIfNeeded()
|
||||
state.selection = mutableList
|
||||
}
|
||||
|
||||
fun selectAll(index: Int) {
|
||||
val category = categories[index]
|
||||
val items = loadedManga[category.id] ?: emptyList()
|
||||
selection.addAll(items.filterNot { it.manga in selection }.map { it.manga })
|
||||
view?.createActionModeIfNeeded()
|
||||
view?.invalidateActionMode()
|
||||
state.selection = state.selection.toMutableList().apply {
|
||||
addAll(items.filterNot { it.manga in selection }.map { it.manga })
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection(index: Int) {
|
||||
val category = categories[index]
|
||||
val items = (loadedManga[category.id] ?: emptyList()).map { it.manga }
|
||||
val invert = items.filterNot { it in selection }
|
||||
selection.removeAll(items)
|
||||
selection.addAll(invert)
|
||||
view?.createActionModeIfNeeded()
|
||||
view?.invalidateActionMode()
|
||||
state.selection = items.filterNot { it in selection }
|
||||
}
|
||||
}
|
||||
|
@ -488,9 +488,13 @@ class MainActivity : BaseActivity() {
|
||||
return
|
||||
}
|
||||
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
|
||||
moveToStartScreen()
|
||||
setSelectedNavItem(startScreenId)
|
||||
} else if (startScreen != null && router.handleBack()) {
|
||||
// Clear selection for Library screen
|
||||
} else if (shouldHandleExitConfirmation()) {
|
||||
// Exit confirmation (resets after 2 seconds)
|
||||
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-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", 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"}
|
||||
|
Loading…
Reference in New Issue
Block a user