Use Compose for Library list and grid (#7520)

This commit is contained in:
Andreas 2022-07-16 21:06:24 +02:00 committed by GitHub
parent 018ca71336
commit 905c96922b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 855 additions and 896 deletions

View File

@ -0,0 +1,48 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun BadgeGroup(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
content: @Composable RowScope.() -> Unit,
) {
Row(modifier = modifier.clip(shape)) {
content()
}
}
@Composable
fun Badge(
text: String,
color: Color = MaterialTheme.colorScheme.secondary,
textColor: Color = MaterialTheme.colorScheme.onSecondary,
shape: Shape = RectangleShape,
) {
Box(
modifier = Modifier
.background(color)
.clip(shape),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
color = textColor,
)
}
}

View File

@ -0,0 +1,30 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.plus
@Composable
fun LazyLibraryGrid(
modifier: Modifier = Modifier,
columns: Int,
content: LazyGridScope.() -> Unit,
) {
LazyVerticalGrid(
modifier = modifier,
columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns),
contentPadding = PaddingValues(8.dp) + WindowInsets.navigationBars.asPaddingValues(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}

View File

@ -0,0 +1,82 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
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.text.font.FontWeight
import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryComfortableGrid(
items: List<LibraryItem>,
columns: Int,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
LazyLibraryGrid(
columns = columns,
) {
items(
items = items,
key = {
it.manga.id!!
},
) { libraryItem ->
LibraryComfortableGridItem(
libraryItem,
libraryItem.manga in selection,
onClick,
onLongClick,
)
}
}
}
@Composable
fun LibraryComfortableGridItem(
item: LibraryItem,
isSelected: Boolean,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
val manga = item.manga
LibraryGridItemSelectable(isSelected = isSelected) {
Column(
modifier = Modifier
.combinedClickable(
onClick = {
onClick(manga)
},
onLongClick = {
onLongClick(manga)
},
),
) {
LibraryGridCover(
mangaCover = MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
downloadCount = item.downloadCount,
unreadCount = item.unreadCount,
isLocal = item.isLocal,
language = item.sourceLanguage,
)
Text(
text = manga.title,
maxLines = 2,
style = LocalTextStyle.current.copy(fontWeight = FontWeight.SemiBold),
)
}
}
}

View File

@ -0,0 +1,104 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryCompactGrid(
items: List<LibraryItem>,
columns: Int,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
LazyLibraryGrid(
columns = columns,
) {
items(
items = items,
key = {
it.manga.id!!
},
) { libraryItem ->
LibraryCompactGridItem(
item = libraryItem,
isSelected = libraryItem.manga in selection,
onClick = onClick,
onLongClick = onLongClick,
)
}
}
}
@Composable
fun LibraryCompactGridItem(
item: LibraryItem,
isSelected: Boolean,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
val manga = item.manga
LibraryGridCover(
modifier = Modifier
.selectedOutline(isSelected)
.combinedClickable(
onClick = {
onClick(manga)
},
onLongClick = {
onLongClick(manga)
},
),
mangaCover = eu.kanade.domain.manga.model.MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
downloadCount = item.downloadCount,
unreadCount = item.unreadCount,
isLocal = item.isLocal,
language = item.sourceLanguage,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
Text(
text = manga.title,
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomStart),
maxLines = 2,
style = LocalTextStyle.current.copy(color = Color.White, fontWeight = FontWeight.SemiBold),
)
}
}

View File

@ -0,0 +1,68 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryCoverOnlyGrid(
items: List<LibraryItem>,
columns: Int,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
LazyLibraryGrid(
columns = columns,
) {
items(
items = items,
key = {
it.manga.id!!
},
) { libraryItem ->
LibraryCoverOnlyGridItem(
item = libraryItem,
isSelected = libraryItem.manga in selection,
onClick = onClick,
onLongClick = onLongClick,
)
}
}
}
@Composable
fun LibraryCoverOnlyGridItem(
item: LibraryItem,
isSelected: Boolean,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
val manga = item.manga
LibraryGridCover(
modifier = Modifier
.selectedOutline(isSelected)
.combinedClickable(
onClick = {
onClick(manga)
},
onLongClick = {
onLongClick(manga)
},
),
mangaCover = eu.kanade.domain.manga.model.MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
downloadCount = item.downloadCount,
unreadCount = item.unreadCount,
isLocal = item.isLocal,
language = item.sourceLanguage,
)
}

View File

@ -0,0 +1,74 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.MangaCover
import eu.kanade.tachiyomi.R
@Composable
fun LibraryGridCover(
modifier: Modifier = Modifier,
mangaCover: eu.kanade.domain.manga.model.MangaCover,
downloadCount: Int,
unreadCount: Int,
isLocal: Boolean,
language: String,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(),
data = mangaCover,
)
content()
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart),
) {
if (downloadCount > 0) {
Badge(
text = "$downloadCount",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (unreadCount > 0) {
Badge(text = "$unreadCount")
}
}
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (isLocal) {
Badge(
text = stringResource(id = R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (isLocal.not() && language.isNotEmpty()) {
Badge(
text = language,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.dp
fun Modifier.selectedOutline(isSelected: Boolean) = composed {
val secondary = MaterialTheme.colorScheme.secondary
if (isSelected) {
drawBehind {
val additional = 24.dp.value
val offset = additional / 2
val height = size.height + additional
val width = size.width + additional
drawRoundRect(
color = secondary,
topLeft = Offset(-offset, -offset),
size = Size(width, height),
cornerRadius = CornerRadius(offset),
)
}
} else {
this
}
}
@Composable
fun LibraryGridItemSelectable(
isSelected: Boolean,
content: @Composable () -> Unit,
) {
Box(Modifier.selectedOutline(isSelected)) {
CompositionLocalProvider(LocalContentColor provides if (isSelected) MaterialTheme.colorScheme.onSecondary else MaterialTheme.colorScheme.onBackground) {
content()
}
}
}

View File

@ -0,0 +1,121 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.selectedBackground
import eu.kanade.presentation.util.verticalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.ui.library.LibraryItem
@Composable
fun LibraryList(
items: List<LibraryItem>,
columns: Int,
selection: List<LibraryManga>,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
LazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = items,
key = {
it.manga.id!!
},
) { libraryItem ->
LibraryListItem(
item = libraryItem,
isSelected = libraryItem.manga in selection,
onClick = onClick,
onLongClick = onLongClick,
)
}
}
}
@Composable
fun LibraryListItem(
item: LibraryItem,
isSelected: Boolean,
onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit,
) {
val manga = item.manga
Row(
modifier = Modifier
.selectedBackground(isSelected)
.height(56.dp)
.combinedClickable(
onClick = { onClick(manga) },
onLongClick = { onLongClick(manga) },
)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
eu.kanade.presentation.components.MangaCover.Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight(),
data = MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
)
Text(
text = manga.title,
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
maxLines = 2,
style = MaterialTheme.typography.bodyMedium,
)
BadgeGroup {
if (item.downloadCount > 0) {
Badge(
text = "${item.downloadCount}",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.unreadCount > 0) {
Badge(text = "${item.unreadCount}")
}
if (item.isLocal) {
Badge(
text = stringResource(id = R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) {
Badge(
text = item.sourceLanguage,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
}
}

View File

@ -1,8 +1,11 @@
package eu.kanade.presentation.util package eu.kanade.presentation.util
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
@ -17,6 +20,15 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun Modifier.selectedBackground(isSelected: Boolean): Modifier = composed {
if (isSelected) {
val alpha = if (isSystemInDarkTheme()) 0.08f else 0.22f
background(MaterialTheme.colorScheme.secondary.copy(alpha = alpha))
} else {
this
}
}
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(.78f) fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(.78f)
fun Modifier.clickableNoIndication( fun Modifier.clickableNoIndication(

View File

@ -3,14 +3,34 @@ package eu.kanade.tachiyomi.ui.library
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
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.ViewCompositionStrategy
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.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.presentation.theme.TachiyomiTheme
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.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -21,13 +41,15 @@ import uy.kohesive.injekt.api.get
*/ */
class LibraryAdapter( class LibraryAdapter(
private val controller: LibraryController, private val controller: LibraryController,
private val presenter: LibraryPresenter,
private val onClickManga: (LibraryManga) -> Unit,
private val preferences: PreferencesHelper = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(),
) : RecyclerViewPagerAdapter() { ) : RecyclerViewPagerAdapter() {
/** /**
* The categories to bind in the adapter. * The categories to bind in the adapter.
*/ */
var categories: List<Category> = emptyList() var categories: List<Category> = mutableStateListOf()
private set private set
/** /**
@ -38,19 +60,6 @@ class LibraryAdapter(
private var boundViews = arrayListOf<View>() private var boundViews = arrayListOf<View>()
private val isPerCategory by lazy { preferences.categorizedDisplaySettings().get() }
private var currentDisplayMode = preferences.libraryDisplayMode().get()
init {
preferences.libraryDisplayMode()
.asFlow()
.drop(1)
.onEach {
currentDisplayMode = it
}
.launchIn(controller.viewScope)
}
/** /**
* Pair of category and size of category * Pair of category and size of category
*/ */
@ -80,10 +89,8 @@ class LibraryAdapter(
* @return a new view. * @return a new view.
*/ */
override fun inflateView(container: ViewGroup, viewType: Int): View { override fun inflateView(container: ViewGroup, viewType: Int): View {
val binding = LibraryCategoryBinding.inflate(LayoutInflater.from(container.context), container, false) val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false)
val view: LibraryCategoryView = binding.root return binding.root
view.onCreate(controller, binding, viewType)
return view
} }
/** /**
@ -93,7 +100,89 @@ class LibraryAdapter(
* @param position the position in the adapter. * @param position the position in the adapter.
*/ */
override fun bindView(view: View, position: Int) { override fun bindView(view: View, position: Int) {
(view as LibraryCategoryView).onBind(categories[position]) (view as ComposeView).apply {
consumeWindowInsets = false
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
TachiyomiTheme {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
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,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = {
presenter.toggleSelection(it)
},
)
}
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) boundViews.add(view)
} }
@ -104,7 +193,6 @@ class LibraryAdapter(
* @param position the position in the adapter. * @param position the position in the adapter.
*/ */
override fun recycleView(view: View, position: Int) { override fun recycleView(view: View, position: Int) {
(view as LibraryCategoryView).onRecycle()
boundViews.remove(view) boundViews.remove(view)
} }
@ -131,45 +219,5 @@ class LibraryAdapter(
} }
} }
/** override fun getViewType(position: Int): Int = -1
* Returns the position of the view.
*/
override fun getItemPosition(obj: Any): Int {
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
val index = categories.indexOfFirst { it.id == view.category.id }
return if (index == -1) POSITION_NONE else index
}
/**
* Called when the view of this adapter is being destroyed.
*/
fun onDestroy() {
for (view in boundViews) {
if (view is LibraryCategoryView) {
view.onDestroy()
}
}
}
override fun getViewType(position: Int): Int {
val category = categories.getOrNull(position)
return if (isPerCategory && category?.id != 0L) {
if (DisplayModeSetting.fromFlag(category?.displayMode) == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
} else {
if (currentDisplayMode == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
}
}
companion object {
const val LIST_DISPLAY_MODE = 1
const val GRID_DISPLAY_MODE = 2
}
} }

View File

@ -1,44 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
/**
* Adapter storing a list of manga in a certain category.
*
* @param view the fragment containing this adapter.
*/
class LibraryCategoryAdapter(view: LibraryCategoryView) :
FlexibleAdapter<LibraryItem>(null, view, true) {
/**
* The list of manga in this category.
*/
private var mangas: List<LibraryItem> = emptyList()
/**
* Sets a list of manga in the adapter.
*
* @param list the list to set.
*/
fun setItems(list: List<LibraryItem>) {
// A copy of manga always unfiltered.
mangas = list.toList()
performFilter()
}
/**
* Returns the position in the adapter for the given manga.
*
* @param manga the manga to find.
*/
fun indexOf(manga: Manga): Int {
return currentItems.indexOfFirst { it.manga.id == manga.id }
}
fun performFilter() {
val s = getFilter(String::class.java) ?: ""
updateDataSet(mangas.filter { it.filter(s) })
}
}

View File

@ -1,328 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.lang.plusAssign
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import rx.subscriptions.CompositeSubscription
import java.util.ArrayDeque
/**
* Fragment containing the library manga for a certain category.
*/
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
private val scope = MainScope()
/**
* The fragment containing this view.
*/
private lateinit var controller: LibraryController
/**
* Category for this view.
*/
lateinit var category: Category
private set
/**
* Recycler view of the list of manga.
*/
private lateinit var recycler: AutofitRecyclerView
/**
* Adapter to hold the manga in this category.
*/
private lateinit var adapter: LibraryCategoryAdapter
/**
* Subscriptions while the view is bound.
*/
private var subscriptions = CompositeSubscription()
private var lastClickPositionStack = ArrayDeque(listOf(-1))
fun onCreate(controller: LibraryController, binding: LibraryCategoryBinding, viewType: Int) {
this.controller = controller
recycler = if (viewType == LibraryAdapter.LIST_DISPLAY_MODE) {
(binding.swipeRefresh.inflate(R.layout.library_list_recycler) as AutofitRecyclerView).apply {
spanCount = 1
}
} else {
(binding.swipeRefresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
spanCount = controller.mangaPerRow
}
}
recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = LibraryCategoryAdapter(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
binding.swipeRefresh.addView(recycler)
adapter.fastScroller = binding.fastScroller
recycler.scrollStateChanges()
.onEach {
// Disable swipe refresh when view is not at the top
val firstPos = (recycler.layoutManager as LinearLayoutManager)
.findFirstCompletelyVisibleItemPosition()
binding.swipeRefresh.isEnabled = firstPos <= 0
}
.launchIn(scope)
recycler.onAnimationsFinished {
(controller.activity as? MainActivity)?.ready = true
}
// Double the distance required to trigger sync
binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
binding.swipeRefresh.refreshes()
.onEach {
if (LibraryUpdateService.start(context, category)) {
context.toast(R.string.updating_category)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
binding.swipeRefresh.isRefreshing = false
}
.launchIn(scope)
}
fun onBind(category: Category) {
this.category = category
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
SelectableAdapter.Mode.MULTI
} else {
SelectableAdapter.Mode.SINGLE
}
subscriptions += controller.searchRelay
.doOnNext { adapter.setFilter(it) }
.skip(1)
.subscribe { adapter.performFilter() }
subscriptions += controller.libraryMangaRelay
.subscribe { onNextLibraryManga(it) }
subscriptions += controller.selectionRelay
.subscribe { onSelectionChanged(it) }
subscriptions += controller.selectAllRelay
.filter { it == category.id }
.subscribe {
adapter.currentItems.forEach { item ->
controller.setSelection(item.manga.toDomainManga()!!, true)
}
controller.invalidateActionMode()
}
subscriptions += controller.selectInverseRelay
.filter { it == category.id }
.subscribe {
adapter.currentItems.forEach { item ->
controller.toggleSelection(item.manga.toDomainManga()!!)
}
controller.invalidateActionMode()
}
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
unsubscribe()
}
fun onDestroy() {
unsubscribe()
scope.cancel()
}
private fun unsubscribe() {
subscriptions.clear()
}
/**
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
* adapter.
*
* @param event the event received.
*/
private fun onNextLibraryManga(event: LibraryMangaEvent) {
// Get the manga list for this category.
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
// Update the category with its manga.
adapter.setItems(mangaForCategory)
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
controller.selectedMangas.forEach { manga ->
val position = adapter.indexOf(manga)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id) as? LibraryHolder<*>)?.toggleActivation()
}
}
}
}
/**
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
* depending on the type of event received.
*
* @param event the selection event received.
*/
private fun onSelectionChanged(event: LibrarySelectionEvent) {
when (event) {
is LibrarySelectionEvent.Selected -> {
if (adapter.mode != SelectableAdapter.Mode.MULTI) {
adapter.mode = SelectableAdapter.Mode.MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
with(adapter.indexOf(event.manga)) {
if (this != -1) lastClickPositionStack.remove(this)
}
if (controller.selectedMangas.isEmpty()) {
adapter.mode = SelectableAdapter.Mode.SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = SelectableAdapter.Mode.SINGLE
adapter.clearSelection()
lastClickPositionStack.clear()
lastClickPositionStack.push(-1)
}
}
}
/**
* Toggles the selection for the given manga and updates the view if needed.
*
* @param manga the manga to toggle.
*/
private fun findAndToggleSelection(manga: Manga) {
val position = adapter.indexOf(manga)
if (position != -1) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id) as? LibraryHolder<*>)?.toggleActivation()
}
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View?, position: Int): Boolean {
// If the action mode is created and the position is valid, toggle the selection.
val item = adapter.getItem(position) ?: return false
return if (adapter.mode == SelectableAdapter.Mode.MULTI) {
if (adapter.isSelected(position)) {
lastClickPositionStack.remove(position)
} else {
lastClickPositionStack.push(position)
}
toggleSelection(position)
true
} else {
openManga(item.manga.toDomainManga()!!)
false
}
}
/**
* Called when a manga is long clicked.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
controller.createActionModeIfNeeded()
val lastClickPosition = lastClickPositionStack.peek()!!
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position ->
for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position ->
for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
if (lastClickPosition != position) {
lastClickPositionStack.remove(position)
lastClickPositionStack.push(position)
}
}
/**
* Opens a manga.
*
* @param manga the manga to open.
*/
private fun openManga(manga: Manga) {
controller.openManga(manga)
}
/**
* Tells the presenter to toggle the selection for the given position.
*
* @param position the position to toggle.
*/
private fun toggleSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga.toDomainManga()!!, !adapter.isSelected(position))
controller.invalidateActionMode()
}
/**
* Tells the presenter to set the selection for the given position.
*
* @param position the position to toggle.
*/
private fun setSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga.toDomainManga()!!, true)
controller.invalidateActionMode()
}
}

View File

@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryComfortableGridHolder(
override val binding: SourceComfortableGridItemBinding,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
) : LibraryHolder<SourceComfortableGridItemBinding>(binding.root, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.badges.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.badges.downloadText) {
isVisible = item.downloadCount > 0
text = item.downloadCount.toString()
}
// Update the source language and its visibility
with(binding.badges.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// set local visibility if its local manga
binding.badges.localText.isVisible = item.isLocal
// Update the cover.
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(item.manga)
}
}

View File

@ -1,72 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "source_compact_grid_item" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param coverOnly true if title should be hidden a.k.a cover only mode.
* @constructor creates a new library holder.
*/
class LibraryCompactGridHolder(
override val binding: SourceCompactGridItemBinding,
adapter: FlexibleAdapter<*>,
private val coverOnly: Boolean,
) : LibraryHolder<SourceCompactGridItemBinding>(binding.root, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.badges.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.badges.downloadText) {
isVisible = item.downloadCount > 0
text = item.downloadCount.toString()
}
// Update the source language and its visibility
with(binding.badges.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// set local visibility if its local manga
binding.badges.localText.isVisible = item.isLocal
// Update the cover.
binding.thumbnail.dispose()
if (coverOnly) {
// Cover only mode: Hides title text unless thumbnail is unavailable
if (!item.manga.thumbnail_url.isNullOrEmpty()) {
binding.thumbnail.loadAutoPause(item.manga)
binding.title.isVisible = false
} else {
binding.title.text = item.manga.title
binding.title.isVisible = true
}
binding.thumbnail.foreground = null
} else {
binding.thumbnail.loadAutoPause(item.manga)
}
}
}

View File

@ -14,16 +14,15 @@ import com.bluelinelabs.conductor.ControllerChangeType
import com.fredporciuncula.flow.preferences.Preference import com.fredporciuncula.flow.preferences.Preference
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.toDbCategory import eu.kanade.domain.category.model.toDbCategory
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R 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.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryControllerBinding import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.TabbedController
@ -33,7 +32,6 @@ import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.preference.asImmediateFlow
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -73,42 +71,11 @@ class LibraryController(
*/ */
private var actionMode: ActionModeWithToolbar? = null private var actionMode: ActionModeWithToolbar? = null
/**
* Currently selected mangas.
*/
val selectedMangas = mutableSetOf<Manga>()
/**
* Relay to notify the UI of selection updates.
*/
val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/**
* Relay to notify search query changes.
*/
val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
/** /**
* Relay to notify the library's viewpager for updates. * Relay to notify the library's viewpager for updates.
*/ */
val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create() val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/**
* Relay to notify the library's viewpager to select all manga
*/
val selectAllRelay: PublishRelay<Long> = PublishRelay.create()
/**
* Relay to notify the library's viewpager to select the inverse
*/
val selectInverseRelay: PublishRelay<Long> = PublishRelay.create()
/**
* Number of manga per row in grid mode.
*/
var mangaPerRow = 0
private set
/** /**
* Adapter of the view pager. * Adapter of the view pager.
*/ */
@ -174,7 +141,19 @@ class LibraryController(
override fun onViewCreated(view: View) { override fun onViewCreated(view: View) {
super.onViewCreated(view) super.onViewCreated(view)
adapter = LibraryAdapter(this) adapter = LibraryAdapter(
controller = this,
presenter = presenter,
onClickManga = {
openManga(it.id!!)
},
)
getColumnsPreferenceForCurrentOrientation()
.asFlow()
.onEach { presenter.columns = it }
.launchIn(viewScope)
binding.libraryPager.adapter = adapter binding.libraryPager.adapter = adapter
binding.libraryPager.pageSelections() binding.libraryPager.pageSelections()
.drop(1) .drop(1)
@ -185,13 +164,7 @@ class LibraryController(
} }
.launchIn(viewScope) .launchIn(viewScope)
getColumnsPreferenceForCurrentOrientation().asImmediateFlow { mangaPerRow = it } if (adapter!!.categories.isNotEmpty()) {
.drop(1)
// Set again the adapter to recalculate the covers height
.onEach { reattachAdapter() }
.launchIn(viewScope)
if (selectedMangas.isNotEmpty()) {
createActionModeIfNeeded() createActionModeIfNeeded()
} }
@ -219,6 +192,14 @@ class LibraryController(
.launchIn(viewScope) .launchIn(viewScope)
} }
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type) super.onChangeStarted(handler, type)
if (type.isEnter) { if (type.isEnter) {
@ -229,7 +210,6 @@ class LibraryController(
override fun onDestroyView(view: View) { override fun onDestroyView(view: View) {
destroyActionModeIfNeeded() destroyActionModeIfNeeded()
adapter?.onDestroy()
adapter = null adapter = null
settingsSheet?.sheetScope?.cancel() settingsSheet?.sheetScope?.cancel()
settingsSheet = null settingsSheet = null
@ -313,6 +293,12 @@ class LibraryController(
} }
} }
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. // Send the manga map to child fragments after the adapter is updated.
libraryMangaRelay.call(LibraryMangaEvent(mangaMap)) libraryMangaRelay.call(LibraryMangaEvent(mangaMap))
@ -320,19 +306,6 @@ class LibraryController(
updateTitle() updateTitle()
} }
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
private fun onFilterChanged() { private fun onFilterChanged() {
presenter.requestFilterUpdate() presenter.requestFilterUpdate()
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
@ -400,7 +373,6 @@ class LibraryController(
} }
private fun performSearch() { private fun performSearch() {
searchRelay.call(presenter.query)
if (presenter.query.isNotEmpty()) { if (presenter.query.isNotEmpty()) {
binding.btnGlobalSearch.isVisible = true binding.btnGlobalSearch.isVisible = true
binding.btnGlobalSearch.text = binding.btnGlobalSearch.text =
@ -455,7 +427,7 @@ class LibraryController(
} }
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = selectedMangas.size val count = presenter.selection.size
if (count == 0) { if (count == 0) {
// Destroy action mode if there are no items selected. // Destroy action mode if there are no items selected.
destroyActionModeIfNeeded() destroyActionModeIfNeeded()
@ -466,9 +438,9 @@ class LibraryController(
} }
override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
if (selectedMangas.isEmpty()) return if (presenter.hasSelection().not()) return
toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible = toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible =
selectedMangas.any { it.source != LocalSource.ID } presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } }
} }
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
@ -487,50 +459,18 @@ class LibraryController(
override fun onDestroyActionMode(mode: ActionMode) { override fun onDestroyActionMode(mode: ActionMode) {
// Clear all the manga selections and notify child views. // Clear all the manga selections and notify child views.
selectedMangas.clear() presenter.clearSelection()
selectionRelay.call(LibrarySelectionEvent.Cleared)
(activity as? MainActivity)?.showBottomNav(true) (activity as? MainActivity)?.showBottomNav(true)
actionMode = null actionMode = null
} }
fun openManga(manga: Manga) { fun openManga(mangaId: Long) {
// Notify the presenter a manga is being opened. // Notify the presenter a manga is being opened.
presenter.onOpenManga() presenter.onOpenManga()
router.pushController(MangaController(manga.id)) router.pushController(MangaController(mangaId))
}
/**
* Sets the selection for a given manga.
*
* @param manga the manga whose selection has changed.
* @param selected whether it's now selected or not.
*/
fun setSelection(manga: Manga, selected: Boolean) {
if (selected) {
if (selectedMangas.add(manga)) {
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
}
} else {
if (selectedMangas.remove(manga)) {
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
}
}
}
/**
* Toggles the current selection state for a given manga.
*
* @param manga the manga whose selection to change.
*/
fun toggleSelection(manga: Manga) {
if (selectedMangas.add(manga)) {
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
} else if (selectedMangas.remove(manga)) {
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
}
} }
/** /**
@ -538,8 +478,7 @@ class LibraryController(
* invalidate the action mode to revert the top toolbar * invalidate the action mode to revert the top toolbar
*/ */
fun clearSelection() { fun clearSelection() {
selectedMangas.clear() presenter.clearSelection()
selectionRelay.call(LibrarySelectionEvent.Cleared)
invalidateActionMode() invalidateActionMode()
} }
@ -549,15 +488,15 @@ class LibraryController(
private fun showMangaCategoriesDialog() { private fun showMangaCategoriesDialog() {
viewScope.launchIO { viewScope.launchIO {
// Create a copy of selected manga // Create a copy of selected manga
val mangas = selectedMangas.toList() val mangas = presenter.selection.toList()
// Hide the default category because it has a different behavior than the ones from db. // Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0L } val categories = presenter.categories.filter { it.id != 0L }
// Get indexes of the common categories to preselect. // Get indexes of the common categories to preselect.
val common = presenter.getCommonCategories(mangas) val common = presenter.getCommonCategories(mangas.mapNotNull { it.toDomainManga() })
// Get indexes of the mix categories to preselect. // Get indexes of the mix categories to preselect.
val mix = presenter.getMixCategories(mangas) val mix = presenter.getMixCategories(mangas.mapNotNull { it.toDomainManga() })
val preselected = categories.map { val preselected = categories.map {
when (it) { when (it) {
in common -> QuadStateTextView.State.CHECKED.ordinal in common -> QuadStateTextView.State.CHECKED.ordinal
@ -566,26 +505,27 @@ class LibraryController(
} }
}.toTypedArray() }.toTypedArray()
launchUI { launchUI {
ChangeMangaCategoriesDialog(this@LibraryController, mangas, categories, preselected) ChangeMangaCategoriesDialog(this@LibraryController, mangas.mapNotNull { it.toDomainManga() }, categories, preselected)
.showDialog(router) .showDialog(router)
} }
} }
} }
private fun downloadUnreadChapters() { private fun downloadUnreadChapters() {
val mangas = selectedMangas.toList() val mangas = presenter.selection.toList()
presenter.downloadUnreadChapters(mangas) presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
destroyActionModeIfNeeded() destroyActionModeIfNeeded()
} }
private fun markReadStatus(read: Boolean) { private fun markReadStatus(read: Boolean) {
val mangas = selectedMangas.toList() val mangas = presenter.selection.toList()
presenter.markReadStatus(mangas, read) presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
destroyActionModeIfNeeded() destroyActionModeIfNeeded()
} }
private fun showDeleteMangaDialog() { private fun showDeleteMangaDialog() {
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router) val mangas = presenter.selection.toList()
DeleteLibraryMangasDialog(this, mangas.mapNotNull { it.toDomainManga() }).showDialog(router)
} }
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) { override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
@ -599,21 +539,18 @@ class LibraryController(
} }
private fun selectAllCategoryManga() { private fun selectAllCategoryManga() {
adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { presenter.selectAll(binding.libraryPager.currentItem)
selectAllRelay.call(it)
}
} }
private fun selectInverseCategoryManga() { private fun selectInverseCategoryManga() {
adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { presenter.invertSelection(binding.libraryPager.currentItem)
selectInverseRelay.call(it)
}
} }
override fun onSearchViewQueryTextChange(newText: String?) { override fun onSearchViewQueryTextChange(newText: String?) {
// Ignore events if this controller isn't at the top to avoid query being reset // Ignore events if this controller isn't at the top to avoid query being reset
if (router.backstack.lastOrNull()?.controller == this) { if (router.backstack.lastOrNull()?.controller == this) {
presenter.query = newText ?: "" presenter.query = newText ?: ""
presenter.searchQuery = newText ?: ""
performSearch() performSearch()
} }
} }

View File

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.viewbinding.ViewBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
/**
* Generic class used to hold the displayed data of a manga in the library.
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to the single tap and long tap events.
*/
abstract class LibraryHolder<VB : ViewBinding>(
view: View,
adapter: FlexibleAdapter<*>,
) : FlexibleViewHolder(view, adapter) {
abstract val binding: VB
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
abstract fun onSetValues(item: LibraryItem)
}

View File

@ -1,27 +1,13 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class LibraryItem( class LibraryItem(
val manga: LibraryManga, val manga: LibraryManga,
private val shouldSetFromCategory: Preference<Boolean>, ) {
private val defaultLibraryDisplayMode: Preference<DisplayModeSetting>,
) :
AbstractFlexibleItem<LibraryHolder<*>>(), IFilterable<String> {
private val sourceManager: SourceManager = Injekt.get() private val sourceManager: SourceManager = Injekt.get()
@ -31,55 +17,13 @@ class LibraryItem(
var isLocal = false var isLocal = false
var sourceLanguage = "" var sourceLanguage = ""
private fun getDisplayMode(): DisplayModeSetting {
return if (shouldSetFromCategory.get() && manga.category != 0) {
DisplayModeSetting.fromFlag(displayMode)
} else {
defaultLibraryDisplayMode.get()
}
}
override fun getLayoutRes(): Int {
return when (getDisplayMode()) {
DisplayModeSetting.COMPACT_GRID, DisplayModeSetting.COVER_ONLY_GRID -> R.layout.source_compact_grid_item
DisplayModeSetting.COMFORTABLE_GRID -> R.layout.source_comfortable_grid_item
DisplayModeSetting.LIST -> R.layout.source_list_item
}
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LibraryHolder<*> {
return when (getDisplayMode()) {
DisplayModeSetting.COMPACT_GRID -> {
LibraryCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter, coverOnly = false)
}
DisplayModeSetting.COVER_ONLY_GRID -> {
LibraryCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter, coverOnly = true)
}
DisplayModeSetting.COMFORTABLE_GRID -> {
LibraryComfortableGridHolder(SourceComfortableGridItemBinding.bind(view), adapter)
}
DisplayModeSetting.LIST -> {
LibraryListHolder(view, adapter)
}
}
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: LibraryHolder<*>,
position: Int,
payloads: List<Any?>?,
) {
holder.onSetValues(this)
}
/** /**
* Filters a manga depending on a query. * Filters a manga depending on a query.
* *
* @param constraint the query to apply. * @param constraint the query to apply.
* @return true if the manga should be included, false otherwise. * @return true if the manga should be included, false otherwise.
*/ */
override fun filter(constraint: String): Boolean { fun filter(constraint: String): Boolean {
val sourceName by lazy { sourceManager.getOrStub(manga.source).name } val sourceName by lazy { sourceManager.getOrStub(manga.source).name }
val genres by lazy { manga.getGenres() } val genres by lazy { manga.getGenres() }
return manga.title.contains(constraint, true) || return manga.title.contains(constraint, true) ||

View File

@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_library_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryListHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>,
) : LibraryHolder<SourceListItemBinding>(view, adapter) {
override val binding = SourceListItemBinding.bind(view)
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.downloadText) {
isVisible = item.downloadCount > 0
text = "${item.downloadCount}"
}
// Update the source language and its visibility
with(binding.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// show local text badge if local manga
binding.localText.isVisible = item.isLocal
// Create thumbnail onclick to simulate long click
binding.thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover
binding.thumbnail.dispose()
binding.thumbnail.load(item.manga)
}
}

View File

@ -1,6 +1,15 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.os.Bundle 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastAny
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.core.util.asObservable import eu.kanade.core.util.asObservable
import eu.kanade.data.DatabaseHandler import eu.kanade.data.DatabaseHandler
@ -18,6 +27,7 @@ import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.track.interactor.GetTracks import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -26,6 +36,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.util.lang.combineLatest import eu.kanade.tachiyomi.util.lang.combineLatest
@ -33,6 +44,12 @@ import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
@ -80,9 +97,24 @@ class LibraryPresenter(
/** /**
* Categories of the library. * Categories of the library.
*/ */
var categories: List<Category> = emptyList() var categories: List<Category> = mutableStateListOf()
private set private set
var loadedManga = mutableStateMapOf<Long, List<LibraryItem>>()
private set
val loadedMangaFlow = MutableStateFlow(loadedManga)
var searchQuery by mutableStateOf(query)
val selection: MutableList<LibraryManga> = mutableStateListOf()
val isPerCategory by mutableStateOf(preferences.categorizedDisplaySettings().get())
var columns by mutableStateOf(0)
var currentDisplayMode by mutableStateOf(preferences.libraryDisplayMode().get())
/** /**
* Relay used to apply the UI filters to the last emission of the library. * Relay used to apply the UI filters to the last emission of the library.
*/ */
@ -105,6 +137,14 @@ class LibraryPresenter(
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
preferences.libraryDisplayMode()
.asFlow()
.drop(1)
.onEach {
currentDisplayMode = it
}
.launchIn(presenterScope)
subscribeLibrary() subscribeLibrary()
} }
@ -416,11 +456,7 @@ class LibraryPresenter(
.map { list -> .map { list ->
list.map { libraryManga -> list.map { libraryManga ->
// Display mode based on user preference: take it from global library setting or category // Display mode based on user preference: take it from global library setting or category
LibraryItem( LibraryItem(libraryManga)
libraryManga,
shouldSetFromCategory,
defaultLibraryDisplayMode,
)
}.groupBy { it.manga.category.toLong() } }.groupBy { it.manga.category.toLong() }
} }
} }
@ -592,4 +628,68 @@ class LibraryPresenter(
} }
} }
} }
@Composable
fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State<List<LibraryItem>> {
val unfiltered = loadedManga[categoryId] ?: emptyList()
return derivedStateOf {
val query = searchQuery
if (query.isNotBlank()) {
unfiltered.filter {
it.filter(query)
}
} else {
unfiltered
}
}
}
@Composable
fun getDisplayMode(index: Int): DisplayModeSetting {
val category = categories[index]
return remember {
if (isPerCategory.not() || category.id == 0L) {
currentDisplayMode
} else {
DisplayModeSetting.fromFlag(category.displayMode)
}
}
}
fun hasSelection(): Boolean {
return selection.isNotEmpty()
}
fun clearSelection() {
selection.clear()
}
fun toggleSelection(manga: LibraryManga) {
if (selection.fastAny { it.id == manga.id }) {
selection.remove(manga)
} else {
selection.add(manga)
}
view?.invalidateActionMode()
view?.createActionModeIfNeeded()
}
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()
}
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()
}
} }

View File

@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.domain.manga.model.Manga
sealed class LibrarySelectionEvent {
class Selected(val manga: Manga) : LibrarySelectionEvent()
class Unselected(val manga: Manga) : LibrarySelectionEvent()
object Cleared : LibrarySelectionEvent()
}

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.ui.library.LibraryCategoryView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</eu.kanade.tachiyomi.ui.library.LibraryCategoryView>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.AutofitRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/library_grid"
style="@style/Widget.Tachiyomi.GridView.Source"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:columnWidth="140dp"
android:paddingStart="5dp"
android:paddingTop="5dp"
android:paddingEnd="5dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/source_compact_grid_item" />

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.AutofitRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/library_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/source_list_item" />