mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Use Compose for Library list and grid (#7520)
This commit is contained in:
		| @@ -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, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
| @@ -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), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
| @@ -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, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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<Category> = emptyList() | ||||
|     var categories: List<Category> = mutableStateListOf() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
| @@ -38,19 +60,6 @@ class LibraryAdapter( | ||||
|  | ||||
|     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 | ||||
|      */ | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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) }) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<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. | ||||
|      */ | ||||
|     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. | ||||
|      */ | ||||
| @@ -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<Int> { | ||||
|         return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { | ||||
|             preferences.portraitColumns() | ||||
|         } else { | ||||
|             preferences.landscapeColumns() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeStarted(handler, type) | ||||
|         if (type.isEnter) { | ||||
| @@ -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<Int> { | ||||
|         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<Manga>, addCategories: List<Category>, removeCategories: List<Category>) { | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
| @@ -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<Boolean>, | ||||
|     private val defaultLibraryDisplayMode: Preference<DisplayModeSetting>, | ||||
| ) : | ||||
|     AbstractFlexibleItem<LibraryHolder<*>>(), IFilterable<String> { | ||||
| ) { | ||||
|  | ||||
|     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<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. | ||||
|      * | ||||
|      * @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) || | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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<Category> = emptyList() | ||||
|     var categories: List<Category> = mutableStateListOf() | ||||
|         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. | ||||
|      */ | ||||
| @@ -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<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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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" /> | ||||
| @@ -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" /> | ||||
		Reference in New Issue
	
	Block a user