From 905c96922bc7059e99c4c7bd89775747d02028a9 Mon Sep 17 00:00:00 2001 From: Andreas Date: Sat, 16 Jul 2022 21:06:24 +0200 Subject: [PATCH] Use Compose for Library list and grid (#7520) --- .../presentation/library/components/Badge.kt | 48 +++ .../library/components/LazyLibraryGrid.kt | 30 ++ .../components/LibraryComfortableGrid.kt | 82 +++++ .../library/components/LibraryCompactGrid.kt | 104 ++++++ .../components/LibraryCoverOnlyGrid.kt | 68 ++++ .../library/components/LibraryGridCover.kt | 74 ++++ .../components/LibraryGridItemSelectable.kt | 46 +++ .../library/components/LibraryList.kt | 121 +++++++ .../eu/kanade/presentation/util/Modifier.kt | 12 + .../tachiyomi/ui/library/LibraryAdapter.kt | 178 ++++++---- .../ui/library/LibraryCategoryAdapter.kt | 44 --- .../ui/library/LibraryCategoryView.kt | 328 ------------------ .../library/LibraryComfortableGridHolder.kt | 61 ---- .../ui/library/LibraryCompactGridHolder.kt | 72 ---- .../tachiyomi/ui/library/LibraryController.kt | 161 +++------ .../tachiyomi/ui/library/LibraryHolder.kt | 29 -- .../tachiyomi/ui/library/LibraryItem.kt | 60 +--- .../tachiyomi/ui/library/LibraryListHolder.kt | 67 ---- .../tachiyomi/ui/library/LibraryPresenter.kt | 112 +++++- .../ui/library/LibrarySelectionEvent.kt | 9 - app/src/main/res/layout/library_category.xml | 22 -- .../main/res/layout/library_grid_recycler.xml | 14 - .../main/res/layout/library_list_recycler.xml | 9 - 23 files changed, 855 insertions(+), 896 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/Badge.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LazyLibraryGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryGridItemSelectable.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryComfortableGridHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCompactGridHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt delete mode 100644 app/src/main/res/layout/library_category.xml delete mode 100644 app/src/main/res/layout/library_grid_recycler.xml delete mode 100644 app/src/main/res/layout/library_list_recycler.xml diff --git a/app/src/main/java/eu/kanade/presentation/library/components/Badge.kt b/app/src/main/java/eu/kanade/presentation/library/components/Badge.kt new file mode 100644 index 0000000000..b73001bd0a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/Badge.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LazyLibraryGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LazyLibraryGrid.kt new file mode 100644 index 0000000000..90e2d7398f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LazyLibraryGrid.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt new file mode 100644 index 0000000000..5b3dc42383 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt @@ -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, + columns: Int, + selection: List, + 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), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt new file mode 100644 index 0000000000..bc83558ac7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt @@ -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, + columns: Int, + selection: List, + 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), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt new file mode 100644 index 0000000000..8f6336f556 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt @@ -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, + columns: Int, + selection: List, + 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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt new file mode 100644 index 0000000000..8cbdabc289 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridItemSelectable.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridItemSelectable.kt new file mode 100644 index 0000000000..4145c9d449 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridItemSelectable.kt @@ -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() + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt new file mode 100644 index 0000000000..f2ed0957e5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt @@ -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, + columns: Int, + selection: List, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Modifier.kt b/app/src/main/java/eu/kanade/presentation/util/Modifier.kt index 3eefaa4618..d3ab2c4735 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Modifier.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Modifier.kt @@ -1,8 +1,11 @@ package eu.kanade.presentation.util +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalMinimumTouchTargetEnforcement +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -17,6 +20,15 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.DpSize 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.clickableNoIndication( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt index 34f9830915..44a8ac56d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt @@ -3,14 +3,34 @@ package eu.kanade.tachiyomi.ui.library import android.view.LayoutInflater import android.view.View 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.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.databinding.LibraryCategoryBinding +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.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.api.get @@ -21,13 +41,15 @@ import uy.kohesive.injekt.api.get */ class LibraryAdapter( private val controller: LibraryController, + 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 = emptyList() + var categories: List = mutableStateListOf() private set /** @@ -38,19 +60,6 @@ class LibraryAdapter( private var boundViews = arrayListOf() - 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 */ @@ -80,10 +89,8 @@ class LibraryAdapter( * @return a new view. */ override fun inflateView(container: ViewGroup, viewType: Int): View { - val binding = LibraryCategoryBinding.inflate(LayoutInflater.from(container.context), container, false) - val view: LibraryCategoryView = binding.root - view.onCreate(controller, binding, viewType) - return view + val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false) + return binding.root } /** @@ -93,7 +100,89 @@ class LibraryAdapter( * @param position the position in the adapter. */ 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) } @@ -104,7 +193,6 @@ class LibraryAdapter( * @param position the position in the adapter. */ override fun recycleView(view: View, position: Int) { - (view as LibraryCategoryView).onRecycle() boundViews.remove(view) } @@ -131,45 +219,5 @@ class LibraryAdapter( } } - /** - * 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 - } + override fun getViewType(position: Int): Int = -1 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt deleted file mode 100644 index 2851426a24..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ /dev/null @@ -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(null, view, true) { - - /** - * The list of manga in this category. - */ - private var mangas: List = emptyList() - - /** - * Sets a list of manga in the adapter. - * - * @param list the list to set. - */ - fun setItems(list: List) { - // 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) }) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt deleted file mode 100644 index f7319beafd..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryComfortableGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryComfortableGridHolder.kt deleted file mode 100644 index 2c3c7fcfea..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryComfortableGridHolder.kt +++ /dev/null @@ -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>, -) : LibraryHolder(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCompactGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCompactGridHolder.kt deleted file mode 100644 index ea699ec7bc..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCompactGridHolder.kt +++ /dev/null @@ -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(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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 6fc1d9f0d1..461ad2281d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -14,16 +14,15 @@ import com.bluelinelabs.conductor.ControllerChangeType import com.fredporciuncula.flow.preferences.Preference import com.google.android.material.tabs.TabLayout import com.jakewharton.rxrelay.BehaviorRelay -import com.jakewharton.rxrelay.PublishRelay 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.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.source.LocalSource import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController 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.util.lang.launchIO 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.openInBrowser import eu.kanade.tachiyomi.util.system.toast @@ -73,42 +71,11 @@ class LibraryController( */ private var actionMode: ActionModeWithToolbar? = null - /** - * Currently selected mangas. - */ - val selectedMangas = mutableSetOf() - - /** - * Relay to notify the UI of selection updates. - */ - val selectionRelay: PublishRelay = PublishRelay.create() - - /** - * Relay to notify search query changes. - */ - val searchRelay: BehaviorRelay = BehaviorRelay.create() - /** * Relay to notify the library's viewpager for updates. */ val libraryMangaRelay: BehaviorRelay = BehaviorRelay.create() - /** - * Relay to notify the library's viewpager to select all manga - */ - val selectAllRelay: PublishRelay = PublishRelay.create() - - /** - * Relay to notify the library's viewpager to select the inverse - */ - val selectInverseRelay: PublishRelay = PublishRelay.create() - - /** - * Number of manga per row in grid mode. - */ - var mangaPerRow = 0 - private set - /** * Adapter of the view pager. */ @@ -174,7 +141,19 @@ class LibraryController( override fun onViewCreated(view: 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.pageSelections() .drop(1) @@ -185,13 +164,7 @@ class LibraryController( } .launchIn(viewScope) - getColumnsPreferenceForCurrentOrientation().asImmediateFlow { mangaPerRow = it } - .drop(1) - // Set again the adapter to recalculate the covers height - .onEach { reattachAdapter() } - .launchIn(viewScope) - - if (selectedMangas.isNotEmpty()) { + if (adapter!!.categories.isNotEmpty()) { createActionModeIfNeeded() } @@ -219,6 +192,14 @@ class LibraryController( .launchIn(viewScope) } + private fun getColumnsPreferenceForCurrentOrientation(): Preference { + 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) { @@ -229,7 +210,6 @@ class LibraryController( override fun onDestroyView(view: View) { destroyActionModeIfNeeded() - adapter?.onDestroy() adapter = null settingsSheet?.sheetScope?.cancel() 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. libraryMangaRelay.call(LibraryMangaEvent(mangaMap)) @@ -320,19 +306,6 @@ class LibraryController( updateTitle() } - /** - * Returns a preference for the number of manga per row based on the current orientation. - * - * @return the preference. - */ - private fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { - preferences.portraitColumns() - } else { - preferences.landscapeColumns() - } - } - private fun onFilterChanged() { presenter.requestFilterUpdate() activity?.invalidateOptionsMenu() @@ -400,7 +373,6 @@ class LibraryController( } private fun performSearch() { - searchRelay.call(presenter.query) if (presenter.query.isNotEmpty()) { binding.btnGlobalSearch.isVisible = true binding.btnGlobalSearch.text = @@ -455,7 +427,7 @@ class LibraryController( } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = selectedMangas.size + val count = presenter.selection.size if (count == 0) { // Destroy action mode if there are no items selected. destroyActionModeIfNeeded() @@ -466,9 +438,9 @@ class LibraryController( } override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { - if (selectedMangas.isEmpty()) return + if (presenter.hasSelection().not()) return 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 { @@ -487,50 +459,18 @@ class LibraryController( override fun onDestroyActionMode(mode: ActionMode) { // Clear all the manga selections and notify child views. - selectedMangas.clear() - selectionRelay.call(LibrarySelectionEvent.Cleared) + presenter.clearSelection() (activity as? MainActivity)?.showBottomNav(true) actionMode = null } - fun openManga(manga: Manga) { + fun openManga(mangaId: Long) { // Notify the presenter a manga is being opened. presenter.onOpenManga() - router.pushController(MangaController(manga.id)) - } - - /** - * 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)) - } + router.pushController(MangaController(mangaId)) } /** @@ -538,8 +478,7 @@ class LibraryController( * invalidate the action mode to revert the top toolbar */ fun clearSelection() { - selectedMangas.clear() - selectionRelay.call(LibrarySelectionEvent.Cleared) + presenter.clearSelection() invalidateActionMode() } @@ -549,15 +488,15 @@ class LibraryController( private fun showMangaCategoriesDialog() { viewScope.launchIO { // 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. val categories = presenter.categories.filter { it.id != 0L } // 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. - val mix = presenter.getMixCategories(mangas) + val mix = presenter.getMixCategories(mangas.mapNotNull { it.toDomainManga() }) val preselected = categories.map { when (it) { in common -> QuadStateTextView.State.CHECKED.ordinal @@ -566,26 +505,27 @@ class LibraryController( } }.toTypedArray() launchUI { - ChangeMangaCategoriesDialog(this@LibraryController, mangas, categories, preselected) + ChangeMangaCategoriesDialog(this@LibraryController, mangas.mapNotNull { it.toDomainManga() }, categories, preselected) .showDialog(router) } } } private fun downloadUnreadChapters() { - val mangas = selectedMangas.toList() - presenter.downloadUnreadChapters(mangas) + val mangas = presenter.selection.toList() + presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() }) destroyActionModeIfNeeded() } private fun markReadStatus(read: Boolean) { - val mangas = selectedMangas.toList() - presenter.markReadStatus(mangas, read) + val mangas = presenter.selection.toList() + presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read) destroyActionModeIfNeeded() } 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, addCategories: List, removeCategories: List) { @@ -599,21 +539,18 @@ class LibraryController( } private fun selectAllCategoryManga() { - adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { - selectAllRelay.call(it) - } + presenter.selectAll(binding.libraryPager.currentItem) } private fun selectInverseCategoryManga() { - adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { - selectInverseRelay.call(it) - } + 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() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt deleted file mode 100644 index 3d36b56cf0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt +++ /dev/null @@ -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( - 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) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index 56c9187d65..a386e86226 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -1,27 +1,13 @@ 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.databinding.SourceComfortableGridItemBinding -import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class LibraryItem( val manga: LibraryManga, - private val shouldSetFromCategory: Preference, - private val defaultLibraryDisplayMode: Preference, -) : - AbstractFlexibleItem>(), IFilterable { +) { private val sourceManager: SourceManager = Injekt.get() @@ -31,55 +17,13 @@ class LibraryItem( var isLocal = false 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>): 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>, - holder: LibraryHolder<*>, - position: Int, - payloads: List?, - ) { - holder.onSetValues(this) - } - /** * Filters a manga depending on a query. * * @param constraint the query to apply. * @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 genres by lazy { manga.getGenres() } return manga.title.contains(constraint, true) || diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt deleted file mode 100644 index 61f9941ac0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 8e9e3b7db9..e6c0b11a39 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -1,6 +1,15 @@ package eu.kanade.tachiyomi.ui.library 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 eu.kanade.core.util.asObservable 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.track.interactor.GetTracks 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.download.DownloadManager 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.online.HttpSource 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.SortModeSetting 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.removeCovers 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 rx.Observable import rx.Subscription @@ -80,9 +97,24 @@ class LibraryPresenter( /** * Categories of the library. */ - var categories: List = emptyList() + var categories: List = mutableStateListOf() private set + var loadedManga = mutableStateMapOf>() + private set + + val loadedMangaFlow = MutableStateFlow(loadedManga) + + var searchQuery by mutableStateOf(query) + + val selection: MutableList = 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. */ @@ -105,6 +137,14 @@ class LibraryPresenter( override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) + preferences.libraryDisplayMode() + .asFlow() + .drop(1) + .onEach { + currentDisplayMode = it + } + .launchIn(presenterScope) + subscribeLibrary() } @@ -416,11 +456,7 @@ class LibraryPresenter( .map { list -> list.map { libraryManga -> // Display mode based on user preference: take it from global library setting or category - LibraryItem( - libraryManga, - shouldSetFromCategory, - defaultLibraryDisplayMode, - ) + LibraryItem(libraryManga) }.groupBy { it.manga.category.toLong() } } } @@ -592,4 +628,68 @@ class LibraryPresenter( } } } + + @Composable + fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State> { + 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() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt deleted file mode 100644 index 7b93e2502f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/res/layout/library_category.xml b/app/src/main/res/layout/library_category.xml deleted file mode 100644 index ff3f50ef7f..0000000000 --- a/app/src/main/res/layout/library_category.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/library_grid_recycler.xml b/app/src/main/res/layout/library_grid_recycler.xml deleted file mode 100644 index e80b60499c..0000000000 --- a/app/src/main/res/layout/library_grid_recycler.xml +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/app/src/main/res/layout/library_list_recycler.xml b/app/src/main/res/layout/library_list_recycler.xml deleted file mode 100644 index 2604c02bd9..0000000000 --- a/app/src/main/res/layout/library_list_recycler.xml +++ /dev/null @@ -1,9 +0,0 @@ - -