Use Compose for Library screen (#7557)

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

View File

@ -161,6 +161,8 @@ dependencies {
implementation(compose.accompanist.webview)
implementation(compose.accompanist.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"
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,14 +3,19 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.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 = {

View File

@ -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 = {

View File

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

View File

@ -1,9 +1,15 @@
package eu.kanade.presentation.library.components
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 = {

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,240 +1,119 @@
package eu.kanade.tachiyomi.ui.library
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()
}
}

View File

@ -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 }
}
}

View File

@ -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() }

View File

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

View File

@ -19,4 +19,6 @@ material-icons = { module = "androidx.compose.material:material-icons-extended",
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
accompanist-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"}