mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-04 08:08:55 +01:00 
			
		
		
		
	MangaController overhaul (#7244)
This commit is contained in:
		@@ -22,6 +22,10 @@ class MangaRepositoryImpl(
 | 
			
		||||
        return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override suspend fun getMangaByIdAsFlow(id: Long): Flow<Manga> {
 | 
			
		||||
        return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
 | 
			
		||||
        return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
 | 
			
		||||
import eu.kanade.domain.manga.interactor.GetMangaById
 | 
			
		||||
import eu.kanade.domain.manga.interactor.GetMangaWithChapters
 | 
			
		||||
import eu.kanade.domain.manga.interactor.ResetViewerFlags
 | 
			
		||||
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
 | 
			
		||||
import eu.kanade.domain.manga.interactor.UpdateManga
 | 
			
		||||
import eu.kanade.domain.manga.repository.MangaRepository
 | 
			
		||||
import eu.kanade.domain.source.interactor.GetEnabledSources
 | 
			
		||||
@@ -71,6 +72,7 @@ class DomainModule : InjektModule {
 | 
			
		||||
        addFactory { GetMangaById(get()) }
 | 
			
		||||
        addFactory { GetNextChapter(get()) }
 | 
			
		||||
        addFactory { ResetViewerFlags(get()) }
 | 
			
		||||
        addFactory { SetMangaChapterFlags(get()) }
 | 
			
		||||
        addFactory { UpdateManga(get()) }
 | 
			
		||||
        addFactory { MoveMangaToCategories(get()) }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -20,4 +20,8 @@ class GetMangaWithChapters(
 | 
			
		||||
            Pair(manga, chapters)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitManga(id: Long): Manga {
 | 
			
		||||
        return mangaRepository.getMangaById(id)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,95 @@
 | 
			
		||||
package eu.kanade.domain.manga.interactor
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga
 | 
			
		||||
import eu.kanade.domain.manga.model.MangaUpdate
 | 
			
		||||
import eu.kanade.domain.manga.repository.MangaRepository
 | 
			
		||||
 | 
			
		||||
class SetMangaChapterFlags(private val mangaRepository: MangaRepository) {
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetDownloadedFilter(manga: Manga, flag: Long): Boolean {
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = manga.id,
 | 
			
		||||
                chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DOWNLOADED_MASK),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetUnreadFilter(manga: Manga, flag: Long): Boolean {
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = manga.id,
 | 
			
		||||
                chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_UNREAD_MASK),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetBookmarkFilter(manga: Manga, flag: Long): Boolean {
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = manga.id,
 | 
			
		||||
                chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_BOOKMARKED_MASK),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetDisplayMode(manga: Manga, flag: Long): Boolean {
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = manga.id,
 | 
			
		||||
                chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DISPLAY_MASK),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetSortingModeOrFlipOrder(manga: Manga, flag: Long): Boolean {
 | 
			
		||||
        val newFlags = manga.chapterFlags.let {
 | 
			
		||||
            if (manga.sorting == flag) {
 | 
			
		||||
                // Just flip the order
 | 
			
		||||
                val orderFlag = if (manga.sortDescending()) {
 | 
			
		||||
                    Manga.CHAPTER_SORT_ASC
 | 
			
		||||
                } else {
 | 
			
		||||
                    Manga.CHAPTER_SORT_DESC
 | 
			
		||||
                }
 | 
			
		||||
                it.setFlag(orderFlag, Manga.CHAPTER_SORT_DIR_MASK)
 | 
			
		||||
            } else {
 | 
			
		||||
                // Set new flag with ascending order
 | 
			
		||||
                it
 | 
			
		||||
                    .setFlag(flag, Manga.CHAPTER_SORTING_MASK)
 | 
			
		||||
                    .setFlag(Manga.CHAPTER_SORT_ASC, Manga.CHAPTER_SORT_DIR_MASK)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = manga.id,
 | 
			
		||||
                chapterFlags = newFlags,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitSetAllFlags(
 | 
			
		||||
        mangaId: Long,
 | 
			
		||||
        unreadFilter: Long,
 | 
			
		||||
        downloadedFilter: Long,
 | 
			
		||||
        bookmarkedFilter: Long,
 | 
			
		||||
        sortingMode: Long,
 | 
			
		||||
        sortingDirection: Long,
 | 
			
		||||
        displayMode: Long,
 | 
			
		||||
    ): Boolean {
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(
 | 
			
		||||
                id = mangaId,
 | 
			
		||||
                chapterFlags = 0L.setFlag(unreadFilter, Manga.CHAPTER_UNREAD_MASK)
 | 
			
		||||
                    .setFlag(downloadedFilter, Manga.CHAPTER_DOWNLOADED_MASK)
 | 
			
		||||
                    .setFlag(bookmarkedFilter, Manga.CHAPTER_BOOKMARKED_MASK)
 | 
			
		||||
                    .setFlag(sortingMode, Manga.CHAPTER_SORTING_MASK)
 | 
			
		||||
                    .setFlag(sortingDirection, Manga.CHAPTER_SORT_DIR_MASK)
 | 
			
		||||
                    .setFlag(displayMode, Manga.CHAPTER_DISPLAY_MASK),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun Long.setFlag(flag: Long, mask: Long): Long {
 | 
			
		||||
        return this and mask.inv() or (flag and mask)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -8,6 +8,8 @@ import eu.kanade.domain.manga.model.toDbManga
 | 
			
		||||
import eu.kanade.domain.manga.repository.MangaRepository
 | 
			
		||||
import eu.kanade.tachiyomi.data.cache.CoverCache
 | 
			
		||||
import tachiyomi.source.model.MangaInfo
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
import java.util.Date
 | 
			
		||||
 | 
			
		||||
class UpdateManga(
 | 
			
		||||
@@ -22,7 +24,7 @@ class UpdateManga(
 | 
			
		||||
        localManga: Manga,
 | 
			
		||||
        remoteManga: MangaInfo,
 | 
			
		||||
        manualFetch: Boolean,
 | 
			
		||||
        coverCache: CoverCache,
 | 
			
		||||
        coverCache: CoverCache = Injekt.get(),
 | 
			
		||||
    ): Boolean {
 | 
			
		||||
        // if the manga isn't a favorite, set its title from source and update in db
 | 
			
		||||
        val title = if (!localManga.favorite) remoteManga.title else null
 | 
			
		||||
@@ -66,4 +68,14 @@ class UpdateManga(
 | 
			
		||||
    suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
 | 
			
		||||
        return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Date().time))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
 | 
			
		||||
        val dateAdded = when (favorite) {
 | 
			
		||||
            true -> Date().time
 | 
			
		||||
            false -> 0
 | 
			
		||||
        }
 | 
			
		||||
        return mangaRepository.update(
 | 
			
		||||
            MangaUpdate(id = mangaId, favorite = favorite, dateAdded = dateAdded),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,8 @@ interface MangaRepository {
 | 
			
		||||
 | 
			
		||||
    suspend fun subscribeMangaById(id: Long): Flow<Manga>
 | 
			
		||||
 | 
			
		||||
    suspend fun getMangaByIdAsFlow(id: Long): Flow<Manga>
 | 
			
		||||
 | 
			
		||||
    fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>>
 | 
			
		||||
 | 
			
		||||
    suspend fun getDuplicateLibraryManga(title: String, sourceId: Long): Manga?
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								app/src/main/java/eu/kanade/presentation/components/Button.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								app/src/main/java/eu/kanade/presentation/components/Button.kt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
package eu.kanade.presentation.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.BorderStroke
 | 
			
		||||
import androidx.compose.foundation.interaction.MutableInteractionSource
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.PaddingValues
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.RowScope
 | 
			
		||||
import androidx.compose.foundation.layout.defaultMinSize
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.material3.ButtonColors
 | 
			
		||||
import androidx.compose.material3.ButtonDefaults
 | 
			
		||||
import androidx.compose.material3.ButtonElevation
 | 
			
		||||
import androidx.compose.material3.LocalContentColor
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.ProvideTextStyle
 | 
			
		||||
import androidx.compose.material3.Shapes
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.CompositionLocalProvider
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.graphics.Shape
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun TextButton(
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    onLongClick: (() -> Unit)? = null,
 | 
			
		||||
    enabled: Boolean = true,
 | 
			
		||||
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
 | 
			
		||||
    elevation: ButtonElevation? = null,
 | 
			
		||||
    shape: Shape = Shapes.Full,
 | 
			
		||||
    border: BorderStroke? = null,
 | 
			
		||||
    colors: ButtonColors = ButtonDefaults.textButtonColors(),
 | 
			
		||||
    contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding,
 | 
			
		||||
    content: @Composable RowScope.() -> Unit,
 | 
			
		||||
) =
 | 
			
		||||
    Button(
 | 
			
		||||
        onClick = onClick,
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        onLongClick = onLongClick,
 | 
			
		||||
        enabled = enabled,
 | 
			
		||||
        interactionSource = interactionSource,
 | 
			
		||||
        elevation = elevation,
 | 
			
		||||
        shape = shape,
 | 
			
		||||
        border = border,
 | 
			
		||||
        colors = colors,
 | 
			
		||||
        contentPadding = contentPadding,
 | 
			
		||||
        content = content,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun Button(
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    onLongClick: (() -> Unit)? = null,
 | 
			
		||||
    enabled: Boolean = true,
 | 
			
		||||
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
 | 
			
		||||
    elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
 | 
			
		||||
    shape: Shape = Shapes.Full,
 | 
			
		||||
    border: BorderStroke? = null,
 | 
			
		||||
    colors: ButtonColors = ButtonDefaults.buttonColors(),
 | 
			
		||||
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
 | 
			
		||||
    content: @Composable RowScope.() -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val containerColor = colors.containerColor(enabled).value
 | 
			
		||||
    val contentColor = colors.contentColor(enabled).value
 | 
			
		||||
    val shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
 | 
			
		||||
    val tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp
 | 
			
		||||
 | 
			
		||||
    Surface(
 | 
			
		||||
        onClick = onClick,
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        onLongClick = onLongClick,
 | 
			
		||||
        shape = shape,
 | 
			
		||||
        color = containerColor,
 | 
			
		||||
        contentColor = contentColor,
 | 
			
		||||
        tonalElevation = tonalElevation,
 | 
			
		||||
        shadowElevation = shadowElevation,
 | 
			
		||||
        border = border,
 | 
			
		||||
        interactionSource = interactionSource,
 | 
			
		||||
        enabled = enabled,
 | 
			
		||||
    ) {
 | 
			
		||||
        CompositionLocalProvider(LocalContentColor provides contentColor) {
 | 
			
		||||
            ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
 | 
			
		||||
                Row(
 | 
			
		||||
                    Modifier.defaultMinSize(
 | 
			
		||||
                        minWidth = ButtonDefaults.MinWidth,
 | 
			
		||||
                        minHeight = ButtonDefaults.MinHeight,
 | 
			
		||||
                    )
 | 
			
		||||
                        .padding(contentPadding),
 | 
			
		||||
                    horizontalArrangement = Arrangement.Center,
 | 
			
		||||
                    verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
                    content = content,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,111 @@
 | 
			
		||||
package eu.kanade.presentation.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.animation.AnimatedVisibility
 | 
			
		||||
import androidx.compose.animation.core.CubicBezierEasing
 | 
			
		||||
import androidx.compose.animation.core.animateDpAsState
 | 
			
		||||
import androidx.compose.animation.core.tween
 | 
			
		||||
import androidx.compose.animation.expandHorizontally
 | 
			
		||||
import androidx.compose.animation.fadeIn
 | 
			
		||||
import androidx.compose.animation.fadeOut
 | 
			
		||||
import androidx.compose.animation.shrinkHorizontally
 | 
			
		||||
import androidx.compose.foundation.interaction.MutableInteractionSource
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.Spacer
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.sizeIn
 | 
			
		||||
import androidx.compose.foundation.layout.width
 | 
			
		||||
import androidx.compose.material3.FloatingActionButton
 | 
			
		||||
import androidx.compose.material3.FloatingActionButtonDefaults
 | 
			
		||||
import androidx.compose.material3.FloatingActionButtonElevation
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.contentColorFor
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.Shape
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun ExtendedFloatingActionButton(
 | 
			
		||||
    text: @Composable () -> Unit,
 | 
			
		||||
    icon: @Composable () -> Unit,
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    expanded: Boolean = true,
 | 
			
		||||
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
 | 
			
		||||
    shape: Shape = MaterialTheme.shapes.large,
 | 
			
		||||
    containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
 | 
			
		||||
    contentColor: Color = contentColorFor(containerColor),
 | 
			
		||||
    elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
 | 
			
		||||
) {
 | 
			
		||||
    val minWidth by animateDpAsState(if (expanded) ExtendedFabMinimumWidth else FabContainerWidth)
 | 
			
		||||
    FloatingActionButton(
 | 
			
		||||
        modifier = modifier.sizeIn(minWidth = minWidth),
 | 
			
		||||
        onClick = onClick,
 | 
			
		||||
        interactionSource = interactionSource,
 | 
			
		||||
        shape = shape,
 | 
			
		||||
        containerColor = containerColor,
 | 
			
		||||
        contentColor = contentColor,
 | 
			
		||||
        elevation = elevation,
 | 
			
		||||
    ) {
 | 
			
		||||
        val startPadding by animateDpAsState(if (expanded) ExtendedFabIconSize / 2 else 0.dp)
 | 
			
		||||
        val endPadding by animateDpAsState(if (expanded) ExtendedFabTextPadding else 0.dp)
 | 
			
		||||
 | 
			
		||||
        Row(
 | 
			
		||||
            modifier = Modifier.padding(start = startPadding, end = endPadding),
 | 
			
		||||
            verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
        ) {
 | 
			
		||||
            icon()
 | 
			
		||||
            AnimatedVisibility(
 | 
			
		||||
                visible = expanded,
 | 
			
		||||
                enter = ExtendedFabExpandAnimation,
 | 
			
		||||
                exit = ExtendedFabCollapseAnimation,
 | 
			
		||||
            ) {
 | 
			
		||||
                Row {
 | 
			
		||||
                    Spacer(Modifier.width(ExtendedFabIconPadding))
 | 
			
		||||
                    text()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
 | 
			
		||||
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
 | 
			
		||||
 | 
			
		||||
private val ExtendedFabMinimumWidth = 80.dp
 | 
			
		||||
private val ExtendedFabIconSize = 24.0.dp
 | 
			
		||||
private val ExtendedFabIconPadding = 12.dp
 | 
			
		||||
private val ExtendedFabTextPadding = 20.dp
 | 
			
		||||
 | 
			
		||||
private val ExtendedFabCollapseAnimation = fadeOut(
 | 
			
		||||
    animationSpec = tween(
 | 
			
		||||
        durationMillis = 100,
 | 
			
		||||
        easing = EasingLinearCubicBezier,
 | 
			
		||||
    ),
 | 
			
		||||
) + shrinkHorizontally(
 | 
			
		||||
    animationSpec = tween(
 | 
			
		||||
        durationMillis = 500,
 | 
			
		||||
        easing = EasingEmphasizedCubicBezier,
 | 
			
		||||
    ),
 | 
			
		||||
    shrinkTowards = Alignment.Start,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
private val ExtendedFabExpandAnimation = fadeIn(
 | 
			
		||||
    animationSpec = tween(
 | 
			
		||||
        durationMillis = 200,
 | 
			
		||||
        delayMillis = 100,
 | 
			
		||||
        easing = EasingLinearCubicBezier,
 | 
			
		||||
    ),
 | 
			
		||||
) + expandHorizontally(
 | 
			
		||||
    animationSpec = tween(
 | 
			
		||||
        durationMillis = 500,
 | 
			
		||||
        easing = EasingEmphasizedCubicBezier,
 | 
			
		||||
    ),
 | 
			
		||||
    expandFrom = Alignment.Start,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
private val FabContainerWidth = 56.0.dp
 | 
			
		||||
							
								
								
									
										108
									
								
								app/src/main/java/eu/kanade/presentation/components/Surface.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								app/src/main/java/eu/kanade/presentation/components/Surface.kt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
package eu.kanade.presentation.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.BorderStroke
 | 
			
		||||
import androidx.compose.foundation.background
 | 
			
		||||
import androidx.compose.foundation.border
 | 
			
		||||
import androidx.compose.foundation.combinedClickable
 | 
			
		||||
import androidx.compose.foundation.interaction.MutableInteractionSource
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.material.ripple.rememberRipple
 | 
			
		||||
import androidx.compose.material3.ColorScheme
 | 
			
		||||
import androidx.compose.material3.LocalAbsoluteTonalElevation
 | 
			
		||||
import androidx.compose.material3.LocalContentColor
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Shapes
 | 
			
		||||
import androidx.compose.material3.contentColorFor
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.CompositionLocalProvider
 | 
			
		||||
import androidx.compose.runtime.NonRestartableComposable
 | 
			
		||||
import androidx.compose.runtime.ReadOnlyComposable
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.clip
 | 
			
		||||
import androidx.compose.ui.draw.shadow
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.Shape
 | 
			
		||||
import androidx.compose.ui.graphics.compositeOver
 | 
			
		||||
import androidx.compose.ui.semantics.Role
 | 
			
		||||
import androidx.compose.ui.unit.Dp
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import eu.kanade.presentation.util.minimumTouchTargetSize
 | 
			
		||||
import kotlin.math.ln
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
@NonRestartableComposable
 | 
			
		||||
fun Surface(
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    onLongClick: (() -> Unit)? = null,
 | 
			
		||||
    enabled: Boolean = true,
 | 
			
		||||
    shape: Shape = Shapes.None,
 | 
			
		||||
    color: Color = MaterialTheme.colorScheme.surface,
 | 
			
		||||
    contentColor: Color = contentColorFor(color),
 | 
			
		||||
    tonalElevation: Dp = 0.dp,
 | 
			
		||||
    shadowElevation: Dp = 0.dp,
 | 
			
		||||
    border: BorderStroke? = null,
 | 
			
		||||
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
 | 
			
		||||
    content: @Composable () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
 | 
			
		||||
    CompositionLocalProvider(
 | 
			
		||||
        LocalContentColor provides contentColor,
 | 
			
		||||
        LocalAbsoluteTonalElevation provides absoluteElevation,
 | 
			
		||||
    ) {
 | 
			
		||||
        Box(
 | 
			
		||||
            modifier = modifier
 | 
			
		||||
                .minimumTouchTargetSize()
 | 
			
		||||
                .surface(
 | 
			
		||||
                    shape = shape,
 | 
			
		||||
                    backgroundColor = surfaceColorAtElevation(
 | 
			
		||||
                        color = color,
 | 
			
		||||
                        elevation = absoluteElevation,
 | 
			
		||||
                    ),
 | 
			
		||||
                    border = border,
 | 
			
		||||
                    shadowElevation = shadowElevation,
 | 
			
		||||
                )
 | 
			
		||||
                .combinedClickable(
 | 
			
		||||
                    interactionSource = interactionSource,
 | 
			
		||||
                    indication = rememberRipple(),
 | 
			
		||||
                    enabled = enabled,
 | 
			
		||||
                    role = Role.Button,
 | 
			
		||||
                    onLongClick = onLongClick,
 | 
			
		||||
                    onClick = onClick,
 | 
			
		||||
                ),
 | 
			
		||||
            propagateMinConstraints = true,
 | 
			
		||||
        ) {
 | 
			
		||||
            content()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private fun Modifier.surface(
 | 
			
		||||
    shape: Shape,
 | 
			
		||||
    backgroundColor: Color,
 | 
			
		||||
    border: BorderStroke?,
 | 
			
		||||
    shadowElevation: Dp,
 | 
			
		||||
) = this
 | 
			
		||||
    .shadow(shadowElevation, shape, clip = false)
 | 
			
		||||
    .then(if (border != null) Modifier.border(border, shape) else Modifier)
 | 
			
		||||
    .background(color = backgroundColor, shape = shape)
 | 
			
		||||
    .clip(shape)
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
@ReadOnlyComposable
 | 
			
		||||
private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
 | 
			
		||||
    return if (color == MaterialTheme.colorScheme.surface) {
 | 
			
		||||
        MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
 | 
			
		||||
    } else {
 | 
			
		||||
        color
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private fun ColorScheme.surfaceColorAtElevation(
 | 
			
		||||
    elevation: Dp,
 | 
			
		||||
): Color {
 | 
			
		||||
    if (elevation == 0.dp) return surface
 | 
			
		||||
    val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
 | 
			
		||||
    return surfaceTint.copy(alpha = alpha).compositeOver(surface)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										803
									
								
								app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										803
									
								
								app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,803 @@
 | 
			
		||||
package eu.kanade.presentation.manga
 | 
			
		||||
 | 
			
		||||
import androidx.activity.compose.BackHandler
 | 
			
		||||
import androidx.compose.animation.AnimatedVisibility
 | 
			
		||||
import androidx.compose.animation.fadeIn
 | 
			
		||||
import androidx.compose.animation.fadeOut
 | 
			
		||||
import androidx.compose.animation.rememberSplineBasedDecay
 | 
			
		||||
import androidx.compose.foundation.gestures.Orientation
 | 
			
		||||
import androidx.compose.foundation.gestures.rememberScrollableState
 | 
			
		||||
import androidx.compose.foundation.gestures.scrollBy
 | 
			
		||||
import androidx.compose.foundation.gestures.scrollable
 | 
			
		||||
import androidx.compose.foundation.interaction.MutableInteractionSource
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.foundation.layout.PaddingValues
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.WindowInsets
 | 
			
		||||
import androidx.compose.foundation.layout.WindowInsetsSides
 | 
			
		||||
import androidx.compose.foundation.layout.asPaddingValues
 | 
			
		||||
import androidx.compose.foundation.layout.calculateEndPadding
 | 
			
		||||
import androidx.compose.foundation.layout.calculateStartPadding
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxHeight
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.navigationBars
 | 
			
		||||
import androidx.compose.foundation.layout.only
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.systemBars
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyColumn
 | 
			
		||||
import androidx.compose.foundation.lazy.items
 | 
			
		||||
import androidx.compose.foundation.lazy.rememberLazyListState
 | 
			
		||||
import androidx.compose.foundation.rememberScrollState
 | 
			
		||||
import androidx.compose.foundation.verticalScroll
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.PlayArrow
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.SnackbarHost
 | 
			
		||||
import androidx.compose.material3.SnackbarHostState
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.rememberTopAppBarScrollState
 | 
			
		||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.SideEffect
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.toMutableStateList
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 | 
			
		||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
 | 
			
		||||
import androidx.compose.ui.layout.onSizeChanged
 | 
			
		||||
import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import androidx.compose.ui.platform.LocalDensity
 | 
			
		||||
import androidx.compose.ui.platform.LocalHapticFeedback
 | 
			
		||||
import androidx.compose.ui.platform.LocalLayoutDirection
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import com.google.accompanist.swiperefresh.SwipeRefresh
 | 
			
		||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
 | 
			
		||||
import eu.kanade.domain.chapter.model.Chapter
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga.Companion.CHAPTER_DISPLAY_NUMBER
 | 
			
		||||
import eu.kanade.presentation.components.ExtendedFloatingActionButton
 | 
			
		||||
import eu.kanade.presentation.components.Scaffold
 | 
			
		||||
import eu.kanade.presentation.components.SwipeRefreshIndicator
 | 
			
		||||
import eu.kanade.presentation.components.VerticalFastScroller
 | 
			
		||||
import eu.kanade.presentation.manga.components.ChapterHeader
 | 
			
		||||
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
 | 
			
		||||
import eu.kanade.presentation.manga.components.MangaChapterListItem
 | 
			
		||||
import eu.kanade.presentation.manga.components.MangaInfoHeader
 | 
			
		||||
import eu.kanade.presentation.manga.components.MangaSmallAppBar
 | 
			
		||||
import eu.kanade.presentation.manga.components.MangaTopAppBar
 | 
			
		||||
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior
 | 
			
		||||
import eu.kanade.presentation.util.isScrolledToEnd
 | 
			
		||||
import eu.kanade.presentation.util.isScrollingUp
 | 
			
		||||
import eu.kanade.presentation.util.plus
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.download.model.Download
 | 
			
		||||
import eu.kanade.tachiyomi.source.SourceManager
 | 
			
		||||
import eu.kanade.tachiyomi.source.getNameForMangaInfo
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.ChapterItem
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
 | 
			
		||||
import kotlinx.coroutines.runBlocking
 | 
			
		||||
import java.text.DecimalFormat
 | 
			
		||||
import java.text.DecimalFormatSymbols
 | 
			
		||||
import java.util.Date
 | 
			
		||||
 | 
			
		||||
private val chapterDecimalFormat = DecimalFormat(
 | 
			
		||||
    "#.###",
 | 
			
		||||
    DecimalFormatSymbols()
 | 
			
		||||
        .apply { decimalSeparator = '.' },
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaScreen(
 | 
			
		||||
    state: MangaScreenState.Success,
 | 
			
		||||
    snackbarHostState: SnackbarHostState,
 | 
			
		||||
    windowWidthSizeClass: WindowWidthSizeClass,
 | 
			
		||||
    onBackClicked: () -> Unit,
 | 
			
		||||
    onChapterClicked: (Chapter) -> Unit,
 | 
			
		||||
    onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
 | 
			
		||||
    onAddToLibraryClicked: () -> Unit,
 | 
			
		||||
    onWebViewClicked: (() -> Unit)?,
 | 
			
		||||
    onTrackingClicked: (() -> Unit)?,
 | 
			
		||||
    onTagClicked: (String) -> Unit,
 | 
			
		||||
    onFilterButtonClicked: () -> Unit,
 | 
			
		||||
    onRefresh: () -> Unit,
 | 
			
		||||
    onContinueReading: () -> Unit,
 | 
			
		||||
    onSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For cover dialog
 | 
			
		||||
    onCoverClicked: () -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For top action menu
 | 
			
		||||
    onShareClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadActionClicked: ((DownloadAction) -> Unit)?,
 | 
			
		||||
    onEditCategoryClicked: (() -> Unit)?,
 | 
			
		||||
    onMigrateClicked: (() -> Unit)?,
 | 
			
		||||
 | 
			
		||||
    // For bottom action menu
 | 
			
		||||
    onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
 | 
			
		||||
    onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
 | 
			
		||||
    onMarkPreviousAsReadClicked: (Chapter) -> Unit,
 | 
			
		||||
    onMultiDeleteClicked: (List<Chapter>) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
 | 
			
		||||
        MangaScreenSmallImpl(
 | 
			
		||||
            state = state,
 | 
			
		||||
            snackbarHostState = snackbarHostState,
 | 
			
		||||
            onBackClicked = onBackClicked,
 | 
			
		||||
            onChapterClicked = onChapterClicked,
 | 
			
		||||
            onDownloadChapter = onDownloadChapter,
 | 
			
		||||
            onAddToLibraryClicked = onAddToLibraryClicked,
 | 
			
		||||
            onWebViewClicked = onWebViewClicked,
 | 
			
		||||
            onTrackingClicked = onTrackingClicked,
 | 
			
		||||
            onTagClicked = onTagClicked,
 | 
			
		||||
            onFilterButtonClicked = onFilterButtonClicked,
 | 
			
		||||
            onRefresh = onRefresh,
 | 
			
		||||
            onContinueReading = onContinueReading,
 | 
			
		||||
            onSearch = onSearch,
 | 
			
		||||
            onCoverClicked = onCoverClicked,
 | 
			
		||||
            onShareClicked = onShareClicked,
 | 
			
		||||
            onDownloadActionClicked = onDownloadActionClicked,
 | 
			
		||||
            onEditCategoryClicked = onEditCategoryClicked,
 | 
			
		||||
            onMigrateClicked = onMigrateClicked,
 | 
			
		||||
            onMultiBookmarkClicked = onMultiBookmarkClicked,
 | 
			
		||||
            onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
 | 
			
		||||
            onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
 | 
			
		||||
            onMultiDeleteClicked = onMultiDeleteClicked,
 | 
			
		||||
        )
 | 
			
		||||
    } else {
 | 
			
		||||
        MangaScreenLargeImpl(
 | 
			
		||||
            state = state,
 | 
			
		||||
            windowWidthSizeClass = windowWidthSizeClass,
 | 
			
		||||
            snackbarHostState = snackbarHostState,
 | 
			
		||||
            onBackClicked = onBackClicked,
 | 
			
		||||
            onChapterClicked = onChapterClicked,
 | 
			
		||||
            onDownloadChapter = onDownloadChapter,
 | 
			
		||||
            onAddToLibraryClicked = onAddToLibraryClicked,
 | 
			
		||||
            onWebViewClicked = onWebViewClicked,
 | 
			
		||||
            onTrackingClicked = onTrackingClicked,
 | 
			
		||||
            onTagClicked = onTagClicked,
 | 
			
		||||
            onFilterButtonClicked = onFilterButtonClicked,
 | 
			
		||||
            onRefresh = onRefresh,
 | 
			
		||||
            onContinueReading = onContinueReading,
 | 
			
		||||
            onSearch = onSearch,
 | 
			
		||||
            onCoverClicked = onCoverClicked,
 | 
			
		||||
            onShareClicked = onShareClicked,
 | 
			
		||||
            onDownloadActionClicked = onDownloadActionClicked,
 | 
			
		||||
            onEditCategoryClicked = onEditCategoryClicked,
 | 
			
		||||
            onMigrateClicked = onMigrateClicked,
 | 
			
		||||
            onMultiBookmarkClicked = onMultiBookmarkClicked,
 | 
			
		||||
            onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
 | 
			
		||||
            onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
 | 
			
		||||
            onMultiDeleteClicked = onMultiDeleteClicked,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun MangaScreenSmallImpl(
 | 
			
		||||
    state: MangaScreenState.Success,
 | 
			
		||||
    snackbarHostState: SnackbarHostState,
 | 
			
		||||
    onBackClicked: () -> Unit,
 | 
			
		||||
    onChapterClicked: (Chapter) -> Unit,
 | 
			
		||||
    onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
 | 
			
		||||
    onAddToLibraryClicked: () -> Unit,
 | 
			
		||||
    onWebViewClicked: (() -> Unit)?,
 | 
			
		||||
    onTrackingClicked: (() -> Unit)?,
 | 
			
		||||
    onTagClicked: (String) -> Unit,
 | 
			
		||||
    onFilterButtonClicked: () -> Unit,
 | 
			
		||||
    onRefresh: () -> Unit,
 | 
			
		||||
    onContinueReading: () -> Unit,
 | 
			
		||||
    onSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For cover dialog
 | 
			
		||||
    onCoverClicked: () -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For top action menu
 | 
			
		||||
    onShareClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadActionClicked: ((DownloadAction) -> Unit)?,
 | 
			
		||||
    onEditCategoryClicked: (() -> Unit)?,
 | 
			
		||||
    onMigrateClicked: (() -> Unit)?,
 | 
			
		||||
 | 
			
		||||
    // For bottom action menu
 | 
			
		||||
    onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
 | 
			
		||||
    onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
 | 
			
		||||
    onMarkPreviousAsReadClicked: (Chapter) -> Unit,
 | 
			
		||||
    onMultiDeleteClicked: (List<Chapter>) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val context = LocalContext.current
 | 
			
		||||
    val layoutDirection = LocalLayoutDirection.current
 | 
			
		||||
    val haptic = LocalHapticFeedback.current
 | 
			
		||||
    val decayAnimationSpec = rememberSplineBasedDecay<Float>()
 | 
			
		||||
    val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec)
 | 
			
		||||
    val chapterListState = rememberLazyListState()
 | 
			
		||||
    SideEffect {
 | 
			
		||||
        if (chapterListState.firstVisibleItemIndex > 0 || chapterListState.firstVisibleItemScrollOffset > 0) {
 | 
			
		||||
            // Should go here after a configuration change
 | 
			
		||||
            // Safe to say that the app bar is fully scrolled
 | 
			
		||||
            scrollBehavior.state.offset = scrollBehavior.state.offsetLimit
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
 | 
			
		||||
    val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) }
 | 
			
		||||
    SwipeRefresh(
 | 
			
		||||
        state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
 | 
			
		||||
        onRefresh = onRefresh,
 | 
			
		||||
        indicatorPadding = PaddingValues(
 | 
			
		||||
            start = insetPadding.calculateStartPadding(layoutDirection),
 | 
			
		||||
            top = with(LocalDensity.current) { topBarHeight.toDp() },
 | 
			
		||||
            end = insetPadding.calculateEndPadding(layoutDirection),
 | 
			
		||||
        ),
 | 
			
		||||
        indicator = { s, trigger ->
 | 
			
		||||
            SwipeRefreshIndicator(
 | 
			
		||||
                state = s,
 | 
			
		||||
                refreshTriggerDistance = trigger,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    ) {
 | 
			
		||||
        val chapters = remember(state) { state.processedChapters.toList() }
 | 
			
		||||
        val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
 | 
			
		||||
        val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
 | 
			
		||||
 | 
			
		||||
        val internalOnBackPressed = {
 | 
			
		||||
            if (selected.isNotEmpty()) {
 | 
			
		||||
                selected.clear()
 | 
			
		||||
            } else {
 | 
			
		||||
                onBackClicked()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        BackHandler(onBack = internalOnBackPressed)
 | 
			
		||||
 | 
			
		||||
        Scaffold(
 | 
			
		||||
            modifier = Modifier
 | 
			
		||||
                .nestedScroll(scrollBehavior.nestedScrollConnection)
 | 
			
		||||
                .padding(insetPadding),
 | 
			
		||||
            topBar = {
 | 
			
		||||
                MangaTopAppBar(
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .scrollable(
 | 
			
		||||
                            state = rememberScrollableState {
 | 
			
		||||
                                var consumed = runBlocking { chapterListState.scrollBy(-it) } * -1
 | 
			
		||||
                                if (consumed == 0f) {
 | 
			
		||||
                                    // Pass scroll to app bar if we're on the top of the list
 | 
			
		||||
                                    val newOffset =
 | 
			
		||||
                                        (scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f)
 | 
			
		||||
                                    consumed = newOffset - scrollBehavior.state.offset
 | 
			
		||||
                                    scrollBehavior.state.offset = newOffset
 | 
			
		||||
                                }
 | 
			
		||||
                                consumed
 | 
			
		||||
                            },
 | 
			
		||||
                            orientation = Orientation.Vertical,
 | 
			
		||||
                            interactionSource = chapterListState.interactionSource as MutableInteractionSource,
 | 
			
		||||
                        ),
 | 
			
		||||
                    title = state.manga.title,
 | 
			
		||||
                    author = state.manga.author,
 | 
			
		||||
                    artist = state.manga.artist,
 | 
			
		||||
                    description = state.manga.description,
 | 
			
		||||
                    tagsProvider = { state.manga.genre },
 | 
			
		||||
                    coverDataProvider = { state.manga },
 | 
			
		||||
                    sourceName = remember { state.source.getNameForMangaInfo() },
 | 
			
		||||
                    isStubSource = remember { state.source is SourceManager.StubSource },
 | 
			
		||||
                    favorite = state.manga.favorite,
 | 
			
		||||
                    status = state.manga.status,
 | 
			
		||||
                    trackingCount = state.trackingCount,
 | 
			
		||||
                    chapterCount = chapters.size,
 | 
			
		||||
                    chapterFiltered = state.manga.chaptersFiltered(),
 | 
			
		||||
                    incognitoMode = state.isIncognitoMode,
 | 
			
		||||
                    downloadedOnlyMode = state.isDownloadedOnlyMode,
 | 
			
		||||
                    fromSource = state.isFromSource,
 | 
			
		||||
                    onBackClicked = internalOnBackPressed,
 | 
			
		||||
                    onCoverClick = onCoverClicked,
 | 
			
		||||
                    onTagClicked = onTagClicked,
 | 
			
		||||
                    onAddToLibraryClicked = onAddToLibraryClicked,
 | 
			
		||||
                    onWebViewClicked = onWebViewClicked,
 | 
			
		||||
                    onTrackingClicked = onTrackingClicked,
 | 
			
		||||
                    onFilterButtonClicked = onFilterButtonClicked,
 | 
			
		||||
                    onShareClicked = onShareClicked,
 | 
			
		||||
                    onDownloadClicked = onDownloadActionClicked,
 | 
			
		||||
                    onEditCategoryClicked = onEditCategoryClicked,
 | 
			
		||||
                    onMigrateClicked = onMigrateClicked,
 | 
			
		||||
                    doGlobalSearch = onSearch,
 | 
			
		||||
                    scrollBehavior = scrollBehavior,
 | 
			
		||||
                    actionModeCounter = selected.size,
 | 
			
		||||
                    onSelectAll = {
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                        selected.addAll(chapters)
 | 
			
		||||
                    },
 | 
			
		||||
                    onInvertSelection = {
 | 
			
		||||
                        val toSelect = chapters - selected
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                        selected.addAll(toSelect)
 | 
			
		||||
                    },
 | 
			
		||||
                    onSmallAppBarHeightChanged = onTopBarHeightChanged,
 | 
			
		||||
                )
 | 
			
		||||
            },
 | 
			
		||||
            bottomBar = {
 | 
			
		||||
                MangaBottomActionMenu(
 | 
			
		||||
                    visible = selected.isNotEmpty(),
 | 
			
		||||
                    modifier = Modifier.fillMaxWidth(),
 | 
			
		||||
                    onBookmarkClicked = {
 | 
			
		||||
                        onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf { selected.any { !it.chapter.bookmark } },
 | 
			
		||||
                    onRemoveBookmarkClicked = {
 | 
			
		||||
                        onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf { selected.all { it.chapter.bookmark } },
 | 
			
		||||
                    onMarkAsReadClicked = {
 | 
			
		||||
                        onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf { selected.any { !it.chapter.read } },
 | 
			
		||||
                    onMarkAsUnreadClicked = {
 | 
			
		||||
                        onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf { selected.any { it.chapter.read } },
 | 
			
		||||
                    onMarkPreviousAsReadClicked = {
 | 
			
		||||
                        onMarkPreviousAsReadClicked(selected[0].chapter)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf { selected.size == 1 },
 | 
			
		||||
                    onDownloadClicked = {
 | 
			
		||||
                        onDownloadChapter!!(selected, ChapterDownloadAction.START)
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf {
 | 
			
		||||
                        onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
 | 
			
		||||
                    },
 | 
			
		||||
                    onDeleteClicked = {
 | 
			
		||||
                        onMultiDeleteClicked(selected.map { it.chapter })
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                    }.takeIf {
 | 
			
		||||
                        onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
            },
 | 
			
		||||
            snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
 | 
			
		||||
            floatingActionButton = {
 | 
			
		||||
                AnimatedVisibility(
 | 
			
		||||
                    visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
 | 
			
		||||
                    enter = fadeIn(),
 | 
			
		||||
                    exit = fadeOut(),
 | 
			
		||||
                ) {
 | 
			
		||||
                    ExtendedFloatingActionButton(
 | 
			
		||||
                        text = {
 | 
			
		||||
                            val id = if (chapters.any { it.chapter.read }) {
 | 
			
		||||
                                R.string.action_resume
 | 
			
		||||
                            } else {
 | 
			
		||||
                                R.string.action_start
 | 
			
		||||
                            }
 | 
			
		||||
                            Text(text = stringResource(id = id))
 | 
			
		||||
                        },
 | 
			
		||||
                        icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
 | 
			
		||||
                        onClick = onContinueReading,
 | 
			
		||||
                        expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
 | 
			
		||||
                        modifier = Modifier
 | 
			
		||||
                            .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        ) { contentPadding ->
 | 
			
		||||
            val withNavBarContentPadding = contentPadding +
 | 
			
		||||
                WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
 | 
			
		||||
            VerticalFastScroller(
 | 
			
		||||
                listState = chapterListState,
 | 
			
		||||
                topContentPadding = withNavBarContentPadding.calculateTopPadding(),
 | 
			
		||||
                endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
 | 
			
		||||
            ) {
 | 
			
		||||
                LazyColumn(
 | 
			
		||||
                    modifier = Modifier.fillMaxHeight(),
 | 
			
		||||
                    state = chapterListState,
 | 
			
		||||
                    contentPadding = withNavBarContentPadding,
 | 
			
		||||
                ) {
 | 
			
		||||
                    items(items = chapters) { chapterItem ->
 | 
			
		||||
                        val (chapter, downloadState, downloadProgress) = chapterItem
 | 
			
		||||
                        val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
 | 
			
		||||
                            if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
 | 
			
		||||
                                chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
 | 
			
		||||
                            } else {
 | 
			
		||||
                                chapter.name
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        val date = remember(chapter.dateUpload) {
 | 
			
		||||
                            chapter.dateUpload
 | 
			
		||||
                                .takeIf { it > 0 }
 | 
			
		||||
                                ?.let { Date(it).toRelativeString(context, state.dateRelativeTime, state.dateFormat) }
 | 
			
		||||
                        }
 | 
			
		||||
                        val lastPageRead = remember(chapter.lastPageRead) {
 | 
			
		||||
                            chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
 | 
			
		||||
                        }
 | 
			
		||||
                        val scanlator = remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
 | 
			
		||||
 | 
			
		||||
                        MangaChapterListItem(
 | 
			
		||||
                            title = chapterTitle,
 | 
			
		||||
                            date = date,
 | 
			
		||||
                            readProgress = lastPageRead?.let { stringResource(id = R.string.chapter_progress, it + 1) },
 | 
			
		||||
                            scanlator = scanlator,
 | 
			
		||||
                            read = chapter.read,
 | 
			
		||||
                            bookmark = chapter.bookmark,
 | 
			
		||||
                            selected = selected.contains(chapterItem),
 | 
			
		||||
                            downloadState = downloadState,
 | 
			
		||||
                            downloadProgress = downloadProgress,
 | 
			
		||||
                            onLongClick = {
 | 
			
		||||
                                val dispatched = onChapterItemLongClick(
 | 
			
		||||
                                    chapterItem = chapterItem,
 | 
			
		||||
                                    selected = selected,
 | 
			
		||||
                                    chapters = chapters,
 | 
			
		||||
                                    selectedPositions = selectedPositions,
 | 
			
		||||
                                )
 | 
			
		||||
                                if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
 | 
			
		||||
                            },
 | 
			
		||||
                            onClick = {
 | 
			
		||||
                                onChapterItemClick(
 | 
			
		||||
                                    chapterItem = chapterItem,
 | 
			
		||||
                                    selected = selected,
 | 
			
		||||
                                    chapters = chapters,
 | 
			
		||||
                                    selectedPositions = selectedPositions,
 | 
			
		||||
                                    onChapterClicked = onChapterClicked,
 | 
			
		||||
                                )
 | 
			
		||||
                            },
 | 
			
		||||
                            onDownloadClick = if (onDownloadChapter != null) {
 | 
			
		||||
                                { onDownloadChapter(listOf(chapterItem), it) }
 | 
			
		||||
                            } else null,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaScreenLargeImpl(
 | 
			
		||||
    state: MangaScreenState.Success,
 | 
			
		||||
    windowWidthSizeClass: WindowWidthSizeClass,
 | 
			
		||||
    snackbarHostState: SnackbarHostState,
 | 
			
		||||
    onBackClicked: () -> Unit,
 | 
			
		||||
    onChapterClicked: (Chapter) -> Unit,
 | 
			
		||||
    onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
 | 
			
		||||
    onAddToLibraryClicked: () -> Unit,
 | 
			
		||||
    onWebViewClicked: (() -> Unit)?,
 | 
			
		||||
    onTrackingClicked: (() -> Unit)?,
 | 
			
		||||
    onTagClicked: (String) -> Unit,
 | 
			
		||||
    onFilterButtonClicked: () -> Unit,
 | 
			
		||||
    onRefresh: () -> Unit,
 | 
			
		||||
    onContinueReading: () -> Unit,
 | 
			
		||||
    onSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For cover dialog
 | 
			
		||||
    onCoverClicked: () -> Unit,
 | 
			
		||||
 | 
			
		||||
    // For top action menu
 | 
			
		||||
    onShareClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadActionClicked: ((DownloadAction) -> Unit)?,
 | 
			
		||||
    onEditCategoryClicked: (() -> Unit)?,
 | 
			
		||||
    onMigrateClicked: (() -> Unit)?,
 | 
			
		||||
 | 
			
		||||
    // For bottom action menu
 | 
			
		||||
    onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
 | 
			
		||||
    onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
 | 
			
		||||
    onMarkPreviousAsReadClicked: (Chapter) -> Unit,
 | 
			
		||||
    onMultiDeleteClicked: (List<Chapter>) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val context = LocalContext.current
 | 
			
		||||
    val layoutDirection = LocalLayoutDirection.current
 | 
			
		||||
    val density = LocalDensity.current
 | 
			
		||||
    val haptic = LocalHapticFeedback.current
 | 
			
		||||
 | 
			
		||||
    val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
 | 
			
		||||
    val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) }
 | 
			
		||||
    SwipeRefresh(
 | 
			
		||||
        state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
 | 
			
		||||
        onRefresh = onRefresh,
 | 
			
		||||
        indicatorPadding = PaddingValues(
 | 
			
		||||
            start = insetPadding.calculateStartPadding(layoutDirection),
 | 
			
		||||
            top = with(density) { topBarHeight.toDp() },
 | 
			
		||||
            end = insetPadding.calculateEndPadding(layoutDirection),
 | 
			
		||||
        ),
 | 
			
		||||
        clipIndicatorToPadding = true,
 | 
			
		||||
        indicator = { s, trigger ->
 | 
			
		||||
            SwipeRefreshIndicator(
 | 
			
		||||
                state = s,
 | 
			
		||||
                refreshTriggerDistance = trigger,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    ) {
 | 
			
		||||
        val chapterListState = rememberLazyListState()
 | 
			
		||||
        val chapters = remember(state) { state.processedChapters.toList() }
 | 
			
		||||
        val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
 | 
			
		||||
        val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
 | 
			
		||||
 | 
			
		||||
        val internalOnBackPressed = {
 | 
			
		||||
            if (selected.isNotEmpty()) {
 | 
			
		||||
                selected.clear()
 | 
			
		||||
            } else {
 | 
			
		||||
                onBackClicked()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        BackHandler(onBack = internalOnBackPressed)
 | 
			
		||||
 | 
			
		||||
        Scaffold(
 | 
			
		||||
            modifier = Modifier.padding(insetPadding),
 | 
			
		||||
            topBar = {
 | 
			
		||||
                MangaSmallAppBar(
 | 
			
		||||
                    modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) },
 | 
			
		||||
                    title = state.manga.title,
 | 
			
		||||
                    titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f },
 | 
			
		||||
                    backgroundAlphaProvider = { 1f },
 | 
			
		||||
                    incognitoMode = state.isIncognitoMode,
 | 
			
		||||
                    downloadedOnlyMode = state.isDownloadedOnlyMode,
 | 
			
		||||
                    onBackClicked = internalOnBackPressed,
 | 
			
		||||
                    onShareClicked = onShareClicked,
 | 
			
		||||
                    onDownloadClicked = onDownloadActionClicked,
 | 
			
		||||
                    onEditCategoryClicked = onEditCategoryClicked,
 | 
			
		||||
                    onMigrateClicked = onMigrateClicked,
 | 
			
		||||
                    actionModeCounter = selected.size,
 | 
			
		||||
                    onSelectAll = {
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                        selected.addAll(chapters)
 | 
			
		||||
                    },
 | 
			
		||||
                    onInvertSelection = {
 | 
			
		||||
                        val toSelect = chapters - selected
 | 
			
		||||
                        selected.clear()
 | 
			
		||||
                        selected.addAll(toSelect)
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
            },
 | 
			
		||||
            bottomBar = {
 | 
			
		||||
                Box(
 | 
			
		||||
                    modifier = Modifier.fillMaxWidth(),
 | 
			
		||||
                    contentAlignment = Alignment.BottomEnd,
 | 
			
		||||
                ) {
 | 
			
		||||
                    MangaBottomActionMenu(
 | 
			
		||||
                        visible = selected.isNotEmpty(),
 | 
			
		||||
                        modifier = Modifier.fillMaxWidth(0.5f),
 | 
			
		||||
                        onBookmarkClicked = {
 | 
			
		||||
                            onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf { selected.any { !it.chapter.bookmark } },
 | 
			
		||||
                        onRemoveBookmarkClicked = {
 | 
			
		||||
                            onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf { selected.all { it.chapter.bookmark } },
 | 
			
		||||
                        onMarkAsReadClicked = {
 | 
			
		||||
                            onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf { selected.any { !it.chapter.read } },
 | 
			
		||||
                        onMarkAsUnreadClicked = {
 | 
			
		||||
                            onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf { selected.any { it.chapter.read } },
 | 
			
		||||
                        onMarkPreviousAsReadClicked = {
 | 
			
		||||
                            onMarkPreviousAsReadClicked(selected[0].chapter)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf { selected.size == 1 },
 | 
			
		||||
                        onDownloadClicked = {
 | 
			
		||||
                            onDownloadChapter!!(selected, ChapterDownloadAction.START)
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf {
 | 
			
		||||
                            onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
 | 
			
		||||
                        },
 | 
			
		||||
                        onDeleteClicked = {
 | 
			
		||||
                            onMultiDeleteClicked(selected.map { it.chapter })
 | 
			
		||||
                            selected.clear()
 | 
			
		||||
                        }.takeIf {
 | 
			
		||||
                            onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
 | 
			
		||||
                        },
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
 | 
			
		||||
            floatingActionButton = {
 | 
			
		||||
                AnimatedVisibility(
 | 
			
		||||
                    visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
 | 
			
		||||
                    enter = fadeIn(),
 | 
			
		||||
                    exit = fadeOut(),
 | 
			
		||||
                ) {
 | 
			
		||||
                    ExtendedFloatingActionButton(
 | 
			
		||||
                        text = {
 | 
			
		||||
                            val id = if (chapters.any { it.chapter.read }) {
 | 
			
		||||
                                R.string.action_resume
 | 
			
		||||
                            } else {
 | 
			
		||||
                                R.string.action_start
 | 
			
		||||
                            }
 | 
			
		||||
                            Text(text = stringResource(id = id))
 | 
			
		||||
                        },
 | 
			
		||||
                        icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
 | 
			
		||||
                        onClick = onContinueReading,
 | 
			
		||||
                        expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
 | 
			
		||||
                        modifier = Modifier
 | 
			
		||||
                            .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        ) { contentPadding ->
 | 
			
		||||
            Row {
 | 
			
		||||
                val withNavBarContentPadding = contentPadding +
 | 
			
		||||
                    WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
 | 
			
		||||
                MangaInfoHeader(
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .weight(1f)
 | 
			
		||||
                        .verticalScroll(rememberScrollState())
 | 
			
		||||
                        .padding(bottom = withNavBarContentPadding.calculateBottomPadding()),
 | 
			
		||||
                    windowWidthSizeClass = WindowWidthSizeClass.Expanded,
 | 
			
		||||
                    appBarPadding = contentPadding.calculateTopPadding(),
 | 
			
		||||
                    title = state.manga.title,
 | 
			
		||||
                    author = state.manga.author,
 | 
			
		||||
                    artist = state.manga.artist,
 | 
			
		||||
                    description = state.manga.description,
 | 
			
		||||
                    tagsProvider = { state.manga.genre },
 | 
			
		||||
                    sourceName = remember { state.source.getNameForMangaInfo() },
 | 
			
		||||
                    isStubSource = remember { state.source is SourceManager.StubSource },
 | 
			
		||||
                    coverDataProvider = { state.manga },
 | 
			
		||||
                    favorite = state.manga.favorite,
 | 
			
		||||
                    status = state.manga.status,
 | 
			
		||||
                    trackingCount = state.trackingCount,
 | 
			
		||||
                    fromSource = state.isFromSource,
 | 
			
		||||
                    onAddToLibraryClicked = onAddToLibraryClicked,
 | 
			
		||||
                    onWebViewClicked = onWebViewClicked,
 | 
			
		||||
                    onTrackingClicked = onTrackingClicked,
 | 
			
		||||
                    onTagClicked = onTagClicked,
 | 
			
		||||
                    onEditCategory = onEditCategoryClicked,
 | 
			
		||||
                    onCoverClick = onCoverClicked,
 | 
			
		||||
                    doSearch = onSearch,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                val chaptersWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f
 | 
			
		||||
                VerticalFastScroller(
 | 
			
		||||
                    listState = chapterListState,
 | 
			
		||||
                    modifier = Modifier.weight(chaptersWeight),
 | 
			
		||||
                    topContentPadding = withNavBarContentPadding.calculateTopPadding(),
 | 
			
		||||
                    endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
 | 
			
		||||
                ) {
 | 
			
		||||
                    LazyColumn(
 | 
			
		||||
                        modifier = Modifier.fillMaxHeight(),
 | 
			
		||||
                        state = chapterListState,
 | 
			
		||||
                        contentPadding = withNavBarContentPadding,
 | 
			
		||||
                    ) {
 | 
			
		||||
                        item(contentType = "header") {
 | 
			
		||||
                            ChapterHeader(
 | 
			
		||||
                                chapterCount = chapters.size,
 | 
			
		||||
                                isChapterFiltered = state.manga.chaptersFiltered(),
 | 
			
		||||
                                onFilterButtonClicked = onFilterButtonClicked,
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        items(items = chapters) { chapterItem ->
 | 
			
		||||
                            val (chapter, downloadState, downloadProgress) = chapterItem
 | 
			
		||||
                            val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
 | 
			
		||||
                                if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
 | 
			
		||||
                                    chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
 | 
			
		||||
                                } else {
 | 
			
		||||
                                    chapter.name
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            val date = remember(chapter.dateUpload) {
 | 
			
		||||
                                chapter.dateUpload
 | 
			
		||||
                                    .takeIf { it > 0 }
 | 
			
		||||
                                    ?.let {
 | 
			
		||||
                                        Date(it).toRelativeString(
 | 
			
		||||
                                            context,
 | 
			
		||||
                                            state.dateRelativeTime,
 | 
			
		||||
                                            state.dateFormat,
 | 
			
		||||
                                        )
 | 
			
		||||
                                    }
 | 
			
		||||
                            }
 | 
			
		||||
                            val lastPageRead = remember(chapter.lastPageRead) {
 | 
			
		||||
                                chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
 | 
			
		||||
                            }
 | 
			
		||||
                            val scanlator =
 | 
			
		||||
                                remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
 | 
			
		||||
 | 
			
		||||
                            MangaChapterListItem(
 | 
			
		||||
                                title = chapterTitle,
 | 
			
		||||
                                date = date,
 | 
			
		||||
                                readProgress = lastPageRead?.let {
 | 
			
		||||
                                    stringResource(
 | 
			
		||||
                                        id = R.string.chapter_progress,
 | 
			
		||||
                                        it + 1,
 | 
			
		||||
                                    )
 | 
			
		||||
                                },
 | 
			
		||||
                                scanlator = scanlator,
 | 
			
		||||
                                read = chapter.read,
 | 
			
		||||
                                bookmark = chapter.bookmark,
 | 
			
		||||
                                selected = selected.contains(chapterItem),
 | 
			
		||||
                                downloadState = downloadState,
 | 
			
		||||
                                downloadProgress = downloadProgress,
 | 
			
		||||
                                onLongClick = {
 | 
			
		||||
                                    val dispatched = onChapterItemLongClick(
 | 
			
		||||
                                        chapterItem = chapterItem,
 | 
			
		||||
                                        selected = selected,
 | 
			
		||||
                                        chapters = chapters,
 | 
			
		||||
                                        selectedPositions = selectedPositions,
 | 
			
		||||
                                    )
 | 
			
		||||
                                    if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
 | 
			
		||||
                                },
 | 
			
		||||
                                onClick = {
 | 
			
		||||
                                    onChapterItemClick(
 | 
			
		||||
                                        chapterItem = chapterItem,
 | 
			
		||||
                                        selected = selected,
 | 
			
		||||
                                        chapters = chapters,
 | 
			
		||||
                                        selectedPositions = selectedPositions,
 | 
			
		||||
                                        onChapterClicked = onChapterClicked,
 | 
			
		||||
                                    )
 | 
			
		||||
                                },
 | 
			
		||||
                                onDownloadClick = if (onDownloadChapter != null) {
 | 
			
		||||
                                    { onDownloadChapter(listOf(chapterItem), it) }
 | 
			
		||||
                                } else null,
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private fun onChapterItemLongClick(
 | 
			
		||||
    chapterItem: ChapterItem,
 | 
			
		||||
    selected: MutableList<ChapterItem>,
 | 
			
		||||
    chapters: List<ChapterItem>,
 | 
			
		||||
    selectedPositions: Array<Int>,
 | 
			
		||||
): Boolean {
 | 
			
		||||
    if (!selected.contains(chapterItem)) {
 | 
			
		||||
        val selectedIndex = chapters.indexOf(chapterItem)
 | 
			
		||||
        if (selected.isEmpty()) {
 | 
			
		||||
            selected.add(chapterItem)
 | 
			
		||||
            selectedPositions[0] = selectedIndex
 | 
			
		||||
            selectedPositions[1] = selectedIndex
 | 
			
		||||
            return true
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Try to select the items in-between when possible
 | 
			
		||||
        val range: IntRange
 | 
			
		||||
        if (selectedIndex < selectedPositions[0]) {
 | 
			
		||||
            range = selectedIndex until selectedPositions[0]
 | 
			
		||||
            selectedPositions[0] = selectedIndex
 | 
			
		||||
        } else if (selectedIndex > selectedPositions[1]) {
 | 
			
		||||
            range = (selectedPositions[1] + 1)..selectedIndex
 | 
			
		||||
            selectedPositions[1] = selectedIndex
 | 
			
		||||
        } else {
 | 
			
		||||
            // Just select itself
 | 
			
		||||
            range = selectedIndex..selectedIndex
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        range.forEach {
 | 
			
		||||
            val toAdd = chapters[it]
 | 
			
		||||
            if (!selected.contains(toAdd)) {
 | 
			
		||||
                selected.add(toAdd)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return true
 | 
			
		||||
    }
 | 
			
		||||
    return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun onChapterItemClick(
 | 
			
		||||
    chapterItem: ChapterItem,
 | 
			
		||||
    selected: MutableList<ChapterItem>,
 | 
			
		||||
    chapters: List<ChapterItem>,
 | 
			
		||||
    selectedPositions: Array<Int>,
 | 
			
		||||
    onChapterClicked: (Chapter) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val selectedIndex = chapters.indexOf(chapterItem)
 | 
			
		||||
    when {
 | 
			
		||||
        selected.contains(chapterItem) -> {
 | 
			
		||||
            val removedIndex = chapters.indexOf(chapterItem)
 | 
			
		||||
            selected.remove(chapterItem)
 | 
			
		||||
 | 
			
		||||
            if (removedIndex == selectedPositions[0]) {
 | 
			
		||||
                selectedPositions[0] = chapters.indexOfFirst { selected.contains(it) }
 | 
			
		||||
            } else if (removedIndex == selectedPositions[1]) {
 | 
			
		||||
                selectedPositions[1] = chapters.indexOfLast { selected.contains(it) }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        selected.isNotEmpty() -> {
 | 
			
		||||
            if (selectedIndex < selectedPositions[0]) {
 | 
			
		||||
                selectedPositions[0] = selectedIndex
 | 
			
		||||
            } else if (selectedIndex > selectedPositions[1]) {
 | 
			
		||||
                selectedPositions[1] = selectedIndex
 | 
			
		||||
            }
 | 
			
		||||
            selected.add(chapterItem)
 | 
			
		||||
        }
 | 
			
		||||
        else -> onChapterClicked(chapterItem.chapter)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,8 +1,12 @@
 | 
			
		||||
package eu.kanade.presentation.manga
 | 
			
		||||
 | 
			
		||||
enum class EditCoverAction {
 | 
			
		||||
    EDIT,
 | 
			
		||||
    DELETE,
 | 
			
		||||
enum class DownloadAction {
 | 
			
		||||
    NEXT_1_CHAPTER,
 | 
			
		||||
    NEXT_5_CHAPTERS,
 | 
			
		||||
    NEXT_10_CHAPTERS,
 | 
			
		||||
    CUSTOM,
 | 
			
		||||
    UNREAD_CHAPTERS,
 | 
			
		||||
    ALL_CHAPTERS
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
enum class ChapterDownloadAction {
 | 
			
		||||
@@ -11,3 +15,8 @@ enum class ChapterDownloadAction {
 | 
			
		||||
    CANCEL,
 | 
			
		||||
    DELETE,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
enum class EditCoverAction {
 | 
			
		||||
    EDIT,
 | 
			
		||||
    DELETE,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,61 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.FilterList
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.IconButton
 | 
			
		||||
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.CompositionLocalProvider
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import eu.kanade.presentation.util.quantityStringResource
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.getResourceColor
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun ChapterHeader(
 | 
			
		||||
    chapterCount: Int?,
 | 
			
		||||
    isChapterFiltered: Boolean,
 | 
			
		||||
    onFilterButtonClicked: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    Row(
 | 
			
		||||
        modifier = Modifier
 | 
			
		||||
            .fillMaxWidth()
 | 
			
		||||
            .padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
 | 
			
		||||
        verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
    ) {
 | 
			
		||||
        Text(
 | 
			
		||||
            text = if (chapterCount == null) {
 | 
			
		||||
                stringResource(id = R.string.chapters)
 | 
			
		||||
            } else {
 | 
			
		||||
                quantityStringResource(id = R.plurals.manga_num_chapters, quantity = chapterCount)
 | 
			
		||||
            },
 | 
			
		||||
            style = MaterialTheme.typography.titleMedium,
 | 
			
		||||
            modifier = Modifier.weight(1f),
 | 
			
		||||
            color = MaterialTheme.colorScheme.onBackground,
 | 
			
		||||
        )
 | 
			
		||||
        CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
 | 
			
		||||
            IconButton(onClick = onFilterButtonClicked) {
 | 
			
		||||
                Icon(
 | 
			
		||||
                    imageVector = Icons.Default.FilterList,
 | 
			
		||||
                    contentDescription = stringResource(id = R.string.action_filter),
 | 
			
		||||
                    tint = if (isChapterFiltered) {
 | 
			
		||||
                        Color(LocalContext.current.getResourceColor(R.attr.colorFilterActive))
 | 
			
		||||
                    } else {
 | 
			
		||||
                        MaterialTheme.colorScheme.onBackground
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun DotSeparatorText() {
 | 
			
		||||
    Text(text = " • ")
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,197 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.animation.AnimatedVisibility
 | 
			
		||||
import androidx.compose.animation.core.animateFloatAsState
 | 
			
		||||
import androidx.compose.animation.expandVertically
 | 
			
		||||
import androidx.compose.animation.fadeIn
 | 
			
		||||
import androidx.compose.animation.fadeOut
 | 
			
		||||
import androidx.compose.animation.shrinkVertically
 | 
			
		||||
import androidx.compose.foundation.combinedClickable
 | 
			
		||||
import androidx.compose.foundation.interaction.MutableInteractionSource
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.RowScope
 | 
			
		||||
import androidx.compose.foundation.layout.navigationBarsPadding
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.size
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.BookmarkAdd
 | 
			
		||||
import androidx.compose.material.icons.filled.BookmarkRemove
 | 
			
		||||
import androidx.compose.material.icons.filled.Delete
 | 
			
		||||
import androidx.compose.material.icons.filled.DoneAll
 | 
			
		||||
import androidx.compose.material.icons.filled.Download
 | 
			
		||||
import androidx.compose.material.icons.filled.RemoveDone
 | 
			
		||||
import androidx.compose.material.ripple.rememberRipple
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Surface
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateListOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.rememberCoroutineScope
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.graphics.vector.ImageVector
 | 
			
		||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 | 
			
		||||
import androidx.compose.ui.platform.LocalHapticFeedback
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.res.vectorResource
 | 
			
		||||
import androidx.compose.ui.text.style.TextOverflow
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import kotlinx.coroutines.Job
 | 
			
		||||
import kotlinx.coroutines.delay
 | 
			
		||||
import kotlinx.coroutines.isActive
 | 
			
		||||
import kotlinx.coroutines.launch
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaBottomActionMenu(
 | 
			
		||||
    visible: Boolean,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    onBookmarkClicked: (() -> Unit)?,
 | 
			
		||||
    onRemoveBookmarkClicked: (() -> Unit)?,
 | 
			
		||||
    onMarkAsReadClicked: (() -> Unit)?,
 | 
			
		||||
    onMarkAsUnreadClicked: (() -> Unit)?,
 | 
			
		||||
    onMarkPreviousAsReadClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadClicked: (() -> Unit)?,
 | 
			
		||||
    onDeleteClicked: (() -> Unit)?,
 | 
			
		||||
) {
 | 
			
		||||
    AnimatedVisibility(
 | 
			
		||||
        visible = visible,
 | 
			
		||||
        enter = expandVertically(expandFrom = Alignment.Bottom),
 | 
			
		||||
        exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
 | 
			
		||||
    ) {
 | 
			
		||||
        val scope = rememberCoroutineScope()
 | 
			
		||||
        Surface(
 | 
			
		||||
            modifier = modifier,
 | 
			
		||||
            shape = MaterialTheme.shapes.large,
 | 
			
		||||
            tonalElevation = 3.dp,
 | 
			
		||||
        ) {
 | 
			
		||||
            val haptic = LocalHapticFeedback.current
 | 
			
		||||
            val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
 | 
			
		||||
            var resetJob: Job? = remember { null }
 | 
			
		||||
            val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
 | 
			
		||||
                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
 | 
			
		||||
                (0 until 7).forEach { i -> confirm[i] = i == toConfirmIndex }
 | 
			
		||||
                resetJob?.cancel()
 | 
			
		||||
                resetJob = scope.launch {
 | 
			
		||||
                    delay(1000)
 | 
			
		||||
                    if (isActive) confirm[toConfirmIndex] = false
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            Row(
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .navigationBarsPadding()
 | 
			
		||||
                    .padding(horizontal = 8.dp, vertical = 12.dp),
 | 
			
		||||
            ) {
 | 
			
		||||
                if (onBookmarkClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_bookmark),
 | 
			
		||||
                        icon = Icons.Default.BookmarkAdd,
 | 
			
		||||
                        toConfirm = confirm[0],
 | 
			
		||||
                        onLongClick = { onLongClickItem(0) },
 | 
			
		||||
                        onClick = onBookmarkClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onRemoveBookmarkClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_remove_bookmark),
 | 
			
		||||
                        icon = Icons.Default.BookmarkRemove,
 | 
			
		||||
                        toConfirm = confirm[1],
 | 
			
		||||
                        onLongClick = { onLongClickItem(1) },
 | 
			
		||||
                        onClick = onRemoveBookmarkClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onMarkAsReadClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_mark_as_read),
 | 
			
		||||
                        icon = Icons.Default.DoneAll,
 | 
			
		||||
                        toConfirm = confirm[2],
 | 
			
		||||
                        onLongClick = { onLongClickItem(2) },
 | 
			
		||||
                        onClick = onMarkAsReadClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onMarkAsUnreadClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_mark_as_unread),
 | 
			
		||||
                        icon = Icons.Default.RemoveDone,
 | 
			
		||||
                        toConfirm = confirm[3],
 | 
			
		||||
                        onLongClick = { onLongClickItem(3) },
 | 
			
		||||
                        onClick = onMarkAsUnreadClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onMarkPreviousAsReadClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_mark_previous_as_read),
 | 
			
		||||
                        icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp),
 | 
			
		||||
                        toConfirm = confirm[4],
 | 
			
		||||
                        onLongClick = { onLongClickItem(4) },
 | 
			
		||||
                        onClick = onMarkPreviousAsReadClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onDownloadClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_download),
 | 
			
		||||
                        icon = Icons.Default.Download,
 | 
			
		||||
                        toConfirm = confirm[5],
 | 
			
		||||
                        onLongClick = { onLongClickItem(5) },
 | 
			
		||||
                        onClick = onDownloadClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                if (onDeleteClicked != null) {
 | 
			
		||||
                    Button(
 | 
			
		||||
                        title = stringResource(id = R.string.action_delete),
 | 
			
		||||
                        icon = Icons.Default.Delete,
 | 
			
		||||
                        toConfirm = confirm[6],
 | 
			
		||||
                        onLongClick = { onLongClickItem(6) },
 | 
			
		||||
                        onClick = onDeleteClicked,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun RowScope.Button(
 | 
			
		||||
    title: String,
 | 
			
		||||
    icon: ImageVector,
 | 
			
		||||
    toConfirm: Boolean,
 | 
			
		||||
    onLongClick: () -> Unit,
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f)
 | 
			
		||||
    Column(
 | 
			
		||||
        modifier = Modifier
 | 
			
		||||
            .size(48.dp)
 | 
			
		||||
            .weight(animatedWeight)
 | 
			
		||||
            .combinedClickable(
 | 
			
		||||
                interactionSource = remember { MutableInteractionSource() },
 | 
			
		||||
                indication = rememberRipple(bounded = false),
 | 
			
		||||
                onLongClick = onLongClick,
 | 
			
		||||
                onClick = onClick,
 | 
			
		||||
            ),
 | 
			
		||||
        verticalArrangement = Arrangement.Center,
 | 
			
		||||
        horizontalAlignment = Alignment.CenterHorizontally,
 | 
			
		||||
    ) {
 | 
			
		||||
        Icon(
 | 
			
		||||
            imageVector = icon,
 | 
			
		||||
            contentDescription = title,
 | 
			
		||||
        )
 | 
			
		||||
        AnimatedVisibility(
 | 
			
		||||
            visible = toConfirm,
 | 
			
		||||
            enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
 | 
			
		||||
            exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
 | 
			
		||||
        ) {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = title,
 | 
			
		||||
                overflow = TextOverflow.Visible,
 | 
			
		||||
                maxLines = 1,
 | 
			
		||||
                style = MaterialTheme.typography.labelSmall,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,139 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.background
 | 
			
		||||
import androidx.compose.foundation.combinedClickable
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.Spacer
 | 
			
		||||
import androidx.compose.foundation.layout.height
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.sizeIn
 | 
			
		||||
import androidx.compose.foundation.layout.width
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.Bookmark
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.ProvideTextStyle
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.alpha
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.platform.LocalDensity
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.text.style.TextOverflow
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import androidx.compose.ui.unit.sp
 | 
			
		||||
import eu.kanade.presentation.components.ChapterDownloadIndicator
 | 
			
		||||
import eu.kanade.presentation.manga.ChapterDownloadAction
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.download.model.Download
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaChapterListItem(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    title: String,
 | 
			
		||||
    date: String?,
 | 
			
		||||
    readProgress: String?,
 | 
			
		||||
    scanlator: String?,
 | 
			
		||||
    read: Boolean,
 | 
			
		||||
    bookmark: Boolean,
 | 
			
		||||
    selected: Boolean,
 | 
			
		||||
    downloadState: Download.State,
 | 
			
		||||
    downloadProgress: Int,
 | 
			
		||||
    onLongClick: () -> Unit,
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    onDownloadClick: ((ChapterDownloadAction) -> Unit)?,
 | 
			
		||||
) {
 | 
			
		||||
    Row(
 | 
			
		||||
        modifier = modifier
 | 
			
		||||
            .background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
 | 
			
		||||
            .combinedClickable(
 | 
			
		||||
                onClick = onClick,
 | 
			
		||||
                onLongClick = onLongClick,
 | 
			
		||||
            )
 | 
			
		||||
            .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
 | 
			
		||||
    ) {
 | 
			
		||||
        Column(
 | 
			
		||||
            modifier = Modifier
 | 
			
		||||
                .weight(1f)
 | 
			
		||||
                .alpha(if (read) ReadItemAlpha else 1f),
 | 
			
		||||
        ) {
 | 
			
		||||
            val textColor = if (bookmark) {
 | 
			
		||||
                MaterialTheme.colorScheme.primary
 | 
			
		||||
            } else {
 | 
			
		||||
                MaterialTheme.colorScheme.onSurface
 | 
			
		||||
            }
 | 
			
		||||
            Row(verticalAlignment = Alignment.CenterVertically) {
 | 
			
		||||
                var textHeight by remember { mutableStateOf(0) }
 | 
			
		||||
                if (bookmark) {
 | 
			
		||||
                    Icon(
 | 
			
		||||
                        imageVector = Icons.Default.Bookmark,
 | 
			
		||||
                        contentDescription = stringResource(id = R.string.action_filter_bookmarked),
 | 
			
		||||
                        modifier = Modifier
 | 
			
		||||
                            .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
 | 
			
		||||
                        tint = textColor,
 | 
			
		||||
                    )
 | 
			
		||||
                    Spacer(modifier = Modifier.width(2.dp))
 | 
			
		||||
                }
 | 
			
		||||
                Text(
 | 
			
		||||
                    text = title,
 | 
			
		||||
                    style = MaterialTheme.typography.bodyMedium
 | 
			
		||||
                        .copy(color = textColor),
 | 
			
		||||
                    maxLines = 1,
 | 
			
		||||
                    overflow = TextOverflow.Ellipsis,
 | 
			
		||||
                    onTextLayout = { textHeight = it.size.height },
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            Spacer(modifier = Modifier.height(6.dp))
 | 
			
		||||
            Row {
 | 
			
		||||
                ProvideTextStyle(
 | 
			
		||||
                    value = MaterialTheme.typography.bodyMedium
 | 
			
		||||
                        .copy(color = textColor, fontSize = 12.sp),
 | 
			
		||||
                ) {
 | 
			
		||||
                    if (date != null) {
 | 
			
		||||
                        Text(
 | 
			
		||||
                            text = date,
 | 
			
		||||
                            maxLines = 1,
 | 
			
		||||
                            overflow = TextOverflow.Ellipsis,
 | 
			
		||||
                        )
 | 
			
		||||
                        if (readProgress != null || scanlator != null) DotSeparatorText()
 | 
			
		||||
                    }
 | 
			
		||||
                    if (readProgress != null) {
 | 
			
		||||
                        Text(
 | 
			
		||||
                            text = readProgress,
 | 
			
		||||
                            maxLines = 1,
 | 
			
		||||
                            overflow = TextOverflow.Ellipsis,
 | 
			
		||||
                            modifier = Modifier.alpha(ReadItemAlpha),
 | 
			
		||||
                        )
 | 
			
		||||
                        if (scanlator != null) DotSeparatorText()
 | 
			
		||||
                    }
 | 
			
		||||
                    if (scanlator != null) {
 | 
			
		||||
                        Text(
 | 
			
		||||
                            text = scanlator,
 | 
			
		||||
                            maxLines = 1,
 | 
			
		||||
                            overflow = TextOverflow.Ellipsis,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Download view
 | 
			
		||||
        if (onDownloadClick != null) {
 | 
			
		||||
            ChapterDownloadIndicator(
 | 
			
		||||
                modifier = Modifier.padding(start = 4.dp),
 | 
			
		||||
                downloadState = downloadState,
 | 
			
		||||
                downloadProgress = downloadProgress,
 | 
			
		||||
                onClick = onDownloadClick,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private const val ReadItemAlpha = .38f
 | 
			
		||||
@@ -0,0 +1,616 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import androidx.compose.animation.animateContentSize
 | 
			
		||||
import androidx.compose.animation.core.animateFloatAsState
 | 
			
		||||
import androidx.compose.animation.graphics.res.animatedVectorResource
 | 
			
		||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
 | 
			
		||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
 | 
			
		||||
import androidx.compose.foundation.background
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.foundation.layout.PaddingValues
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.RowScope
 | 
			
		||||
import androidx.compose.foundation.layout.Spacer
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.height
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.size
 | 
			
		||||
import androidx.compose.foundation.layout.sizeIn
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyRow
 | 
			
		||||
import androidx.compose.foundation.lazy.items
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.AttachMoney
 | 
			
		||||
import androidx.compose.material.icons.filled.Block
 | 
			
		||||
import androidx.compose.material.icons.filled.Close
 | 
			
		||||
import androidx.compose.material.icons.filled.Done
 | 
			
		||||
import androidx.compose.material.icons.filled.DoneAll
 | 
			
		||||
import androidx.compose.material.icons.filled.Favorite
 | 
			
		||||
import androidx.compose.material.icons.filled.FavoriteBorder
 | 
			
		||||
import androidx.compose.material.icons.filled.Pause
 | 
			
		||||
import androidx.compose.material.icons.filled.Public
 | 
			
		||||
import androidx.compose.material.icons.filled.Schedule
 | 
			
		||||
import androidx.compose.material.icons.filled.Sync
 | 
			
		||||
import androidx.compose.material.icons.filled.Warning
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.ProvideTextStyle
 | 
			
		||||
import androidx.compose.material3.SuggestionChip
 | 
			
		||||
import androidx.compose.material3.SuggestionChipDefaults
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.CompositionLocalProvider
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.saveable.rememberSaveable
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.alpha
 | 
			
		||||
import androidx.compose.ui.draw.clipToBounds
 | 
			
		||||
import androidx.compose.ui.draw.drawWithContent
 | 
			
		||||
import androidx.compose.ui.graphics.Brush
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.vector.ImageVector
 | 
			
		||||
import androidx.compose.ui.layout.ContentScale
 | 
			
		||||
import androidx.compose.ui.layout.SubcomposeLayout
 | 
			
		||||
import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import androidx.compose.ui.platform.LocalDensity
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.text.style.TextAlign
 | 
			
		||||
import androidx.compose.ui.unit.Constraints
 | 
			
		||||
import androidx.compose.ui.unit.Dp
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import androidx.compose.ui.unit.sp
 | 
			
		||||
import coil.compose.AsyncImage
 | 
			
		||||
import com.google.accompanist.flowlayout.FlowRow
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga
 | 
			
		||||
import eu.kanade.presentation.components.MangaCover
 | 
			
		||||
import eu.kanade.presentation.components.TextButton
 | 
			
		||||
import eu.kanade.presentation.util.clickableNoIndication
 | 
			
		||||
import eu.kanade.presentation.util.quantityStringResource
 | 
			
		||||
import eu.kanade.presentation.util.secondaryItemAlpha
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SManga
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
 | 
			
		||||
import kotlin.math.roundToInt
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaInfoHeader(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    windowWidthSizeClass: WindowWidthSizeClass,
 | 
			
		||||
    appBarPadding: Dp,
 | 
			
		||||
    title: String,
 | 
			
		||||
    author: String?,
 | 
			
		||||
    artist: String?,
 | 
			
		||||
    description: String?,
 | 
			
		||||
    tagsProvider: () -> List<String>?,
 | 
			
		||||
    sourceName: String,
 | 
			
		||||
    isStubSource: Boolean,
 | 
			
		||||
    coverDataProvider: () -> Manga,
 | 
			
		||||
    favorite: Boolean,
 | 
			
		||||
    status: Long,
 | 
			
		||||
    trackingCount: Int,
 | 
			
		||||
    fromSource: Boolean,
 | 
			
		||||
    onAddToLibraryClicked: () -> Unit,
 | 
			
		||||
    onWebViewClicked: (() -> Unit)?,
 | 
			
		||||
    onTrackingClicked: (() -> Unit)?,
 | 
			
		||||
    onTagClicked: (String) -> Unit,
 | 
			
		||||
    onEditCategory: (() -> Unit)?,
 | 
			
		||||
    onCoverClick: () -> Unit,
 | 
			
		||||
    doSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val context = LocalContext.current
 | 
			
		||||
    Column(modifier = modifier) {
 | 
			
		||||
        Box {
 | 
			
		||||
            // Backdrop
 | 
			
		||||
            val backdropGradientColors = listOf(
 | 
			
		||||
                Color.Transparent,
 | 
			
		||||
                MaterialTheme.colorScheme.background,
 | 
			
		||||
            )
 | 
			
		||||
            AsyncImage(
 | 
			
		||||
                model = coverDataProvider(),
 | 
			
		||||
                contentDescription = null,
 | 
			
		||||
                contentScale = ContentScale.Crop,
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .matchParentSize()
 | 
			
		||||
                    .drawWithContent {
 | 
			
		||||
                        drawContent()
 | 
			
		||||
                        drawRect(
 | 
			
		||||
                            brush = Brush.verticalGradient(colors = backdropGradientColors),
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                    .alpha(.2f),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            // Manga & source info
 | 
			
		||||
            if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
 | 
			
		||||
                MangaAndSourceTitlesSmall(
 | 
			
		||||
                    appBarPadding = appBarPadding,
 | 
			
		||||
                    coverDataProvider = coverDataProvider,
 | 
			
		||||
                    onCoverClick = onCoverClick,
 | 
			
		||||
                    title = title,
 | 
			
		||||
                    context = context,
 | 
			
		||||
                    doSearch = doSearch,
 | 
			
		||||
                    author = author,
 | 
			
		||||
                    artist = artist,
 | 
			
		||||
                    status = status,
 | 
			
		||||
                    sourceName = sourceName,
 | 
			
		||||
                    isStubSource = isStubSource,
 | 
			
		||||
                )
 | 
			
		||||
            } else {
 | 
			
		||||
                MangaAndSourceTitlesLarge(
 | 
			
		||||
                    appBarPadding = appBarPadding,
 | 
			
		||||
                    coverDataProvider = coverDataProvider,
 | 
			
		||||
                    onCoverClick = onCoverClick,
 | 
			
		||||
                    title = title,
 | 
			
		||||
                    context = context,
 | 
			
		||||
                    doSearch = doSearch,
 | 
			
		||||
                    author = author,
 | 
			
		||||
                    artist = artist,
 | 
			
		||||
                    status = status,
 | 
			
		||||
                    sourceName = sourceName,
 | 
			
		||||
                    isStubSource = isStubSource,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Action buttons
 | 
			
		||||
        Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
 | 
			
		||||
            val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
 | 
			
		||||
            MangaActionButton(
 | 
			
		||||
                title = if (favorite) {
 | 
			
		||||
                    stringResource(id = R.string.in_library)
 | 
			
		||||
                } else {
 | 
			
		||||
                    stringResource(id = R.string.add_to_library)
 | 
			
		||||
                },
 | 
			
		||||
                icon = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
 | 
			
		||||
                color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
 | 
			
		||||
                onClick = onAddToLibraryClicked,
 | 
			
		||||
                onLongClick = onEditCategory,
 | 
			
		||||
            )
 | 
			
		||||
            if (onTrackingClicked != null) {
 | 
			
		||||
                MangaActionButton(
 | 
			
		||||
                    title = if (trackingCount == 0) {
 | 
			
		||||
                        stringResource(id = R.string.manga_tracking_tab)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        quantityStringResource(id = R.plurals.num_trackers, quantity = trackingCount, trackingCount)
 | 
			
		||||
                    },
 | 
			
		||||
                    icon = if (trackingCount == 0) Icons.Default.Sync else Icons.Default.Done,
 | 
			
		||||
                    color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary,
 | 
			
		||||
                    onClick = onTrackingClicked,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            if (onWebViewClicked != null) {
 | 
			
		||||
                MangaActionButton(
 | 
			
		||||
                    title = stringResource(id = R.string.action_web_view),
 | 
			
		||||
                    icon = Icons.Default.Public,
 | 
			
		||||
                    color = defaultActionButtonColor,
 | 
			
		||||
                    onClick = onWebViewClicked,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Expandable description-tags
 | 
			
		||||
        Column {
 | 
			
		||||
            val (expanded, onExpanded) = rememberSaveable {
 | 
			
		||||
                mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact)
 | 
			
		||||
            }
 | 
			
		||||
            if (!description.isNullOrBlank()) {
 | 
			
		||||
                val trimmedDescription = remember(description) {
 | 
			
		||||
                    description
 | 
			
		||||
                        .replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
 | 
			
		||||
                        .replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
 | 
			
		||||
                }
 | 
			
		||||
                MangaSummary(
 | 
			
		||||
                    expandedDescription = description,
 | 
			
		||||
                    shrunkDescription = trimmedDescription,
 | 
			
		||||
                    expanded = expanded,
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .padding(top = 8.dp)
 | 
			
		||||
                        .padding(horizontal = 16.dp)
 | 
			
		||||
                        .clickableNoIndication(
 | 
			
		||||
                            onLongClick = { context.copyToClipboard(description, description) },
 | 
			
		||||
                            onClick = { onExpanded(!expanded) },
 | 
			
		||||
                        ),
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            val tags = tagsProvider()
 | 
			
		||||
            if (!tags.isNullOrEmpty()) {
 | 
			
		||||
                Box(
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .padding(top = 8.dp)
 | 
			
		||||
                        .padding(vertical = 12.dp)
 | 
			
		||||
                        .animateContentSize(),
 | 
			
		||||
                ) {
 | 
			
		||||
                    if (expanded) {
 | 
			
		||||
                        FlowRow(
 | 
			
		||||
                            modifier = Modifier.padding(horizontal = 16.dp),
 | 
			
		||||
                            mainAxisSpacing = 4.dp,
 | 
			
		||||
                            crossAxisSpacing = 8.dp,
 | 
			
		||||
                        ) {
 | 
			
		||||
                            tags.forEach {
 | 
			
		||||
                                TagsChip(
 | 
			
		||||
                                    text = it,
 | 
			
		||||
                                    onClick = { onTagClicked(it) },
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        LazyRow(
 | 
			
		||||
                            contentPadding = PaddingValues(horizontal = 16.dp),
 | 
			
		||||
                            horizontalArrangement = Arrangement.spacedBy(4.dp),
 | 
			
		||||
                        ) {
 | 
			
		||||
                            items(items = tags) {
 | 
			
		||||
                                TagsChip(
 | 
			
		||||
                                    text = it,
 | 
			
		||||
                                    onClick = { onTagClicked(it) },
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun MangaAndSourceTitlesLarge(
 | 
			
		||||
    appBarPadding: Dp,
 | 
			
		||||
    coverDataProvider: () -> Manga,
 | 
			
		||||
    onCoverClick: () -> Unit,
 | 
			
		||||
    title: String,
 | 
			
		||||
    context: Context,
 | 
			
		||||
    doSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
    author: String?,
 | 
			
		||||
    artist: String?,
 | 
			
		||||
    status: Long,
 | 
			
		||||
    sourceName: String,
 | 
			
		||||
    isStubSource: Boolean,
 | 
			
		||||
) {
 | 
			
		||||
    Column(
 | 
			
		||||
        modifier = Modifier
 | 
			
		||||
            .fillMaxWidth()
 | 
			
		||||
            .padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
 | 
			
		||||
        horizontalAlignment = Alignment.CenterHorizontally,
 | 
			
		||||
    ) {
 | 
			
		||||
        MangaCover.Book(
 | 
			
		||||
            modifier = Modifier.fillMaxWidth(0.4f),
 | 
			
		||||
            data = coverDataProvider(),
 | 
			
		||||
            onClick = onCoverClick,
 | 
			
		||||
        )
 | 
			
		||||
        Spacer(modifier = Modifier.height(16.dp))
 | 
			
		||||
        Text(
 | 
			
		||||
            text = title.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown),
 | 
			
		||||
            style = MaterialTheme.typography.titleLarge,
 | 
			
		||||
            modifier = Modifier.clickableNoIndication(
 | 
			
		||||
                onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
 | 
			
		||||
                onClick = { if (title.isNotBlank()) doSearch(title, true) },
 | 
			
		||||
            ),
 | 
			
		||||
            textAlign = TextAlign.Center,
 | 
			
		||||
        )
 | 
			
		||||
        Spacer(modifier = Modifier.height(2.dp))
 | 
			
		||||
        Text(
 | 
			
		||||
            text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
 | 
			
		||||
            style = MaterialTheme.typography.titleSmall,
 | 
			
		||||
            modifier = Modifier
 | 
			
		||||
                .secondaryItemAlpha()
 | 
			
		||||
                .padding(top = 2.dp)
 | 
			
		||||
                .clickableNoIndication(
 | 
			
		||||
                    onLongClick = {
 | 
			
		||||
                        if (!author.isNullOrBlank()) context.copyToClipboard(
 | 
			
		||||
                            author,
 | 
			
		||||
                            author,
 | 
			
		||||
                        )
 | 
			
		||||
                    },
 | 
			
		||||
                    onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
 | 
			
		||||
                ),
 | 
			
		||||
            textAlign = TextAlign.Center,
 | 
			
		||||
        )
 | 
			
		||||
        if (!artist.isNullOrBlank()) {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = artist,
 | 
			
		||||
                style = MaterialTheme.typography.titleSmall,
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .secondaryItemAlpha()
 | 
			
		||||
                    .padding(top = 2.dp)
 | 
			
		||||
                    .clickableNoIndication(
 | 
			
		||||
                        onLongClick = { context.copyToClipboard(artist, artist) },
 | 
			
		||||
                        onClick = { doSearch(artist, true) },
 | 
			
		||||
                    ),
 | 
			
		||||
                textAlign = TextAlign.Center,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        Spacer(modifier = Modifier.height(4.dp))
 | 
			
		||||
        Row(
 | 
			
		||||
            modifier = Modifier.secondaryItemAlpha(),
 | 
			
		||||
            verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
        ) {
 | 
			
		||||
            Icon(
 | 
			
		||||
                imageVector = when (status) {
 | 
			
		||||
                    SManga.ONGOING.toLong() -> Icons.Default.Schedule
 | 
			
		||||
                    SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
 | 
			
		||||
                    SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
 | 
			
		||||
                    SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
 | 
			
		||||
                    SManga.CANCELLED.toLong() -> Icons.Default.Close
 | 
			
		||||
                    SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
 | 
			
		||||
                    else -> Icons.Default.Block
 | 
			
		||||
                },
 | 
			
		||||
                contentDescription = null,
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .padding(end = 4.dp)
 | 
			
		||||
                    .size(16.dp),
 | 
			
		||||
            )
 | 
			
		||||
            ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
 | 
			
		||||
                Text(
 | 
			
		||||
                    text = when (status) {
 | 
			
		||||
                        SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
 | 
			
		||||
                        SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
 | 
			
		||||
                        SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
 | 
			
		||||
                        SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
 | 
			
		||||
                        SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
 | 
			
		||||
                        SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
 | 
			
		||||
                        else -> stringResource(id = R.string.unknown)
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
                DotSeparatorText()
 | 
			
		||||
                if (isStubSource) {
 | 
			
		||||
                    Icon(
 | 
			
		||||
                        imageVector = Icons.Default.Warning,
 | 
			
		||||
                        contentDescription = null,
 | 
			
		||||
                        modifier = Modifier
 | 
			
		||||
                            .padding(end = 4.dp)
 | 
			
		||||
                            .size(16.dp),
 | 
			
		||||
                        tint = MaterialTheme.colorScheme.error,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                Text(
 | 
			
		||||
                    text = sourceName,
 | 
			
		||||
                    modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun MangaAndSourceTitlesSmall(
 | 
			
		||||
    appBarPadding: Dp,
 | 
			
		||||
    coverDataProvider: () -> Manga,
 | 
			
		||||
    onCoverClick: () -> Unit,
 | 
			
		||||
    title: String,
 | 
			
		||||
    context: Context,
 | 
			
		||||
    doSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
    author: String?,
 | 
			
		||||
    artist: String?,
 | 
			
		||||
    status: Long,
 | 
			
		||||
    sourceName: String,
 | 
			
		||||
    isStubSource: Boolean,
 | 
			
		||||
) {
 | 
			
		||||
    Row(
 | 
			
		||||
        modifier = Modifier
 | 
			
		||||
            .fillMaxWidth()
 | 
			
		||||
            .padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
 | 
			
		||||
        verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
    ) {
 | 
			
		||||
        MangaCover.Book(
 | 
			
		||||
            modifier = Modifier.sizeIn(maxWidth = 100.dp),
 | 
			
		||||
            data = coverDataProvider(),
 | 
			
		||||
            onClick = onCoverClick,
 | 
			
		||||
        )
 | 
			
		||||
        Column(modifier = Modifier.padding(start = 16.dp)) {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = title.ifBlank { stringResource(id = R.string.unknown) },
 | 
			
		||||
                style = MaterialTheme.typography.titleLarge,
 | 
			
		||||
                modifier = Modifier.clickableNoIndication(
 | 
			
		||||
                    onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
 | 
			
		||||
                    onClick = { if (title.isNotBlank()) doSearch(title, true) },
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            Spacer(modifier = Modifier.height(2.dp))
 | 
			
		||||
            Text(
 | 
			
		||||
                text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
 | 
			
		||||
                style = MaterialTheme.typography.titleSmall,
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .secondaryItemAlpha()
 | 
			
		||||
                    .padding(top = 2.dp)
 | 
			
		||||
                    .clickableNoIndication(
 | 
			
		||||
                        onLongClick = {
 | 
			
		||||
                            if (!author.isNullOrBlank()) context.copyToClipboard(
 | 
			
		||||
                                author,
 | 
			
		||||
                                author,
 | 
			
		||||
                            )
 | 
			
		||||
                        },
 | 
			
		||||
                        onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
 | 
			
		||||
                    ),
 | 
			
		||||
            )
 | 
			
		||||
            if (!artist.isNullOrBlank()) {
 | 
			
		||||
                Text(
 | 
			
		||||
                    text = artist,
 | 
			
		||||
                    style = MaterialTheme.typography.titleSmall,
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .secondaryItemAlpha()
 | 
			
		||||
                        .padding(top = 2.dp)
 | 
			
		||||
                        .clickableNoIndication(
 | 
			
		||||
                            onLongClick = { context.copyToClipboard(artist, artist) },
 | 
			
		||||
                            onClick = { doSearch(artist, true) },
 | 
			
		||||
                        ),
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
            Spacer(modifier = Modifier.height(4.dp))
 | 
			
		||||
            Row(
 | 
			
		||||
                modifier = Modifier.secondaryItemAlpha(),
 | 
			
		||||
                verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
            ) {
 | 
			
		||||
                Icon(
 | 
			
		||||
                    imageVector = when (status) {
 | 
			
		||||
                        SManga.ONGOING.toLong() -> Icons.Default.Schedule
 | 
			
		||||
                        SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
 | 
			
		||||
                        SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
 | 
			
		||||
                        SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
 | 
			
		||||
                        SManga.CANCELLED.toLong() -> Icons.Default.Close
 | 
			
		||||
                        SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
 | 
			
		||||
                        else -> Icons.Default.Block
 | 
			
		||||
                    },
 | 
			
		||||
                    contentDescription = null,
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .padding(end = 4.dp)
 | 
			
		||||
                        .size(16.dp),
 | 
			
		||||
                )
 | 
			
		||||
                ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
 | 
			
		||||
                    Text(
 | 
			
		||||
                        text = when (status) {
 | 
			
		||||
                            SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
 | 
			
		||||
                            SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
 | 
			
		||||
                            SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
 | 
			
		||||
                            SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
 | 
			
		||||
                            SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
 | 
			
		||||
                            SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
 | 
			
		||||
                            else -> stringResource(id = R.string.unknown)
 | 
			
		||||
                        },
 | 
			
		||||
                    )
 | 
			
		||||
                    DotSeparatorText()
 | 
			
		||||
                    if (isStubSource) {
 | 
			
		||||
                        Icon(
 | 
			
		||||
                            imageVector = Icons.Default.Warning,
 | 
			
		||||
                            contentDescription = null,
 | 
			
		||||
                            modifier = Modifier
 | 
			
		||||
                                .padding(end = 4.dp)
 | 
			
		||||
                                .size(16.dp),
 | 
			
		||||
                            tint = MaterialTheme.colorScheme.error,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                    Text(
 | 
			
		||||
                        text = sourceName,
 | 
			
		||||
                        modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun MangaSummary(
 | 
			
		||||
    expandedDescription: String,
 | 
			
		||||
    shrunkDescription: String,
 | 
			
		||||
    expanded: Boolean,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
) {
 | 
			
		||||
    var expandedHeight by remember { mutableStateOf(0) }
 | 
			
		||||
    var shrunkHeight by remember { mutableStateOf(0) }
 | 
			
		||||
    val heightDelta = remember(expandedHeight, shrunkHeight) { expandedHeight - shrunkHeight }
 | 
			
		||||
    val animProgress by animateFloatAsState(if (expanded) 1f else 0f)
 | 
			
		||||
    val scrimHeight = with(LocalDensity.current) { remember { 24.sp.roundToPx() } }
 | 
			
		||||
 | 
			
		||||
    SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints ->
 | 
			
		||||
        val shrunkPlaceable = subcompose("description-s") {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = "\n\n", // Shows at least 3 lines
 | 
			
		||||
                style = MaterialTheme.typography.bodyMedium,
 | 
			
		||||
            )
 | 
			
		||||
        }.map { it.measure(constraints) }
 | 
			
		||||
        shrunkHeight = shrunkPlaceable.maxByOrNull { it.height }?.height ?: 0
 | 
			
		||||
 | 
			
		||||
        val expandedPlaceable = subcompose("description-l") {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = expandedDescription,
 | 
			
		||||
                style = MaterialTheme.typography.bodyMedium,
 | 
			
		||||
            )
 | 
			
		||||
        }.map { it.measure(constraints) }
 | 
			
		||||
        expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0
 | 
			
		||||
 | 
			
		||||
        val actualPlaceable = subcompose("description") {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = if (expanded) expandedDescription else shrunkDescription,
 | 
			
		||||
                maxLines = Int.MAX_VALUE,
 | 
			
		||||
                style = MaterialTheme.typography.bodyMedium,
 | 
			
		||||
                color = MaterialTheme.colorScheme.onBackground,
 | 
			
		||||
                modifier = Modifier.secondaryItemAlpha(),
 | 
			
		||||
            )
 | 
			
		||||
        }.map { it.measure(constraints) }
 | 
			
		||||
 | 
			
		||||
        val scrimPlaceable = subcompose("scrim") {
 | 
			
		||||
            val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
 | 
			
		||||
            Box(
 | 
			
		||||
                modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
 | 
			
		||||
                contentAlignment = Alignment.Center,
 | 
			
		||||
            ) {
 | 
			
		||||
                val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
 | 
			
		||||
                Icon(
 | 
			
		||||
                    painter = rememberAnimatedVectorPainter(image, !expanded),
 | 
			
		||||
                    contentDescription = null,
 | 
			
		||||
                    tint = MaterialTheme.colorScheme.onBackground,
 | 
			
		||||
                    modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }.map { it.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight)) }
 | 
			
		||||
 | 
			
		||||
        val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt()
 | 
			
		||||
        layout(constraints.maxWidth, currentHeight) {
 | 
			
		||||
            actualPlaceable.forEach {
 | 
			
		||||
                it.place(0, 0)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            val scrimY = currentHeight - scrimHeight
 | 
			
		||||
            scrimPlaceable.forEach {
 | 
			
		||||
                it.place(0, scrimY)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun TagsChip(
 | 
			
		||||
    text: String,
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
 | 
			
		||||
        SuggestionChip(
 | 
			
		||||
            onClick = onClick,
 | 
			
		||||
            label = { Text(text = text, style = MaterialTheme.typography.bodySmall) },
 | 
			
		||||
            border = null,
 | 
			
		||||
            colors = SuggestionChipDefaults.suggestionChipColors(
 | 
			
		||||
                containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
 | 
			
		||||
                labelColor = MaterialTheme.colorScheme.onSurface,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun RowScope.MangaActionButton(
 | 
			
		||||
    title: String,
 | 
			
		||||
    icon: ImageVector,
 | 
			
		||||
    color: Color,
 | 
			
		||||
    onClick: () -> Unit,
 | 
			
		||||
    onLongClick: (() -> Unit)? = null,
 | 
			
		||||
) {
 | 
			
		||||
    TextButton(
 | 
			
		||||
        onClick = onClick,
 | 
			
		||||
        modifier = Modifier.weight(1f),
 | 
			
		||||
        onLongClick = onLongClick,
 | 
			
		||||
    ) {
 | 
			
		||||
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
 | 
			
		||||
            Icon(
 | 
			
		||||
                imageVector = icon,
 | 
			
		||||
                contentDescription = null,
 | 
			
		||||
                tint = color,
 | 
			
		||||
                modifier = Modifier.size(20.dp),
 | 
			
		||||
            )
 | 
			
		||||
            Spacer(Modifier.height(4.dp))
 | 
			
		||||
            Text(
 | 
			
		||||
                text = title,
 | 
			
		||||
                color = color,
 | 
			
		||||
                fontSize = 12.sp,
 | 
			
		||||
                textAlign = TextAlign.Center,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,237 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.background
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.foundation.layout.WindowInsets
 | 
			
		||||
import androidx.compose.foundation.layout.WindowInsetsSides
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.only
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.layout.systemBars
 | 
			
		||||
import androidx.compose.foundation.layout.windowInsetsPadding
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.ArrowBack
 | 
			
		||||
import androidx.compose.material.icons.filled.Close
 | 
			
		||||
import androidx.compose.material.icons.filled.Download
 | 
			
		||||
import androidx.compose.material.icons.filled.FlipToBack
 | 
			
		||||
import androidx.compose.material.icons.filled.MoreVert
 | 
			
		||||
import androidx.compose.material.icons.filled.SelectAll
 | 
			
		||||
import androidx.compose.material.icons.filled.Share
 | 
			
		||||
import androidx.compose.material3.DropdownMenuItem
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.IconButton
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.SmallTopAppBar
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.TopAppBarDefaults
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.alpha
 | 
			
		||||
import androidx.compose.ui.draw.drawBehind
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.text.style.TextAlign
 | 
			
		||||
import androidx.compose.ui.text.style.TextOverflow
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import eu.kanade.presentation.components.DropdownMenu
 | 
			
		||||
import eu.kanade.presentation.manga.DownloadAction
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaSmallAppBar(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    title: String,
 | 
			
		||||
    titleAlphaProvider: () -> Float,
 | 
			
		||||
    backgroundAlphaProvider: () -> Float = titleAlphaProvider,
 | 
			
		||||
    incognitoMode: Boolean,
 | 
			
		||||
    downloadedOnlyMode: Boolean,
 | 
			
		||||
    onBackClicked: () -> Unit,
 | 
			
		||||
    onShareClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadClicked: ((DownloadAction) -> Unit)?,
 | 
			
		||||
    onEditCategoryClicked: (() -> Unit)?,
 | 
			
		||||
    onMigrateClicked: (() -> Unit)?,
 | 
			
		||||
    // For action mode
 | 
			
		||||
    actionModeCounter: Int,
 | 
			
		||||
    onSelectAll: () -> Unit,
 | 
			
		||||
    onInvertSelection: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val isActionMode = actionModeCounter > 0
 | 
			
		||||
    val backgroundAlpha = if (isActionMode) 1f else backgroundAlphaProvider()
 | 
			
		||||
    val backgroundColor by TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f)
 | 
			
		||||
    Column(
 | 
			
		||||
        modifier = modifier.drawBehind {
 | 
			
		||||
            drawRect(backgroundColor.copy(alpha = backgroundAlpha))
 | 
			
		||||
        },
 | 
			
		||||
    ) {
 | 
			
		||||
        SmallTopAppBar(
 | 
			
		||||
            modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
 | 
			
		||||
            title = {
 | 
			
		||||
                Text(
 | 
			
		||||
                    text = if (isActionMode) actionModeCounter.toString() else title,
 | 
			
		||||
                    maxLines = 1,
 | 
			
		||||
                    overflow = TextOverflow.Ellipsis,
 | 
			
		||||
                    modifier = Modifier.alpha(titleAlphaProvider()),
 | 
			
		||||
                )
 | 
			
		||||
            },
 | 
			
		||||
            navigationIcon = {
 | 
			
		||||
                IconButton(onClick = onBackClicked) {
 | 
			
		||||
                    Icon(
 | 
			
		||||
                        imageVector = if (isActionMode) Icons.Default.Close else Icons.Default.ArrowBack,
 | 
			
		||||
                        contentDescription = stringResource(id = R.string.abc_action_bar_up_description),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            actions = {
 | 
			
		||||
                if (isActionMode) {
 | 
			
		||||
                    IconButton(onClick = onSelectAll) {
 | 
			
		||||
                        Icon(
 | 
			
		||||
                            imageVector = Icons.Default.SelectAll,
 | 
			
		||||
                            contentDescription = stringResource(id = R.string.action_select_all),
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                    IconButton(onClick = onInvertSelection) {
 | 
			
		||||
                        Icon(
 | 
			
		||||
                            imageVector = Icons.Default.FlipToBack,
 | 
			
		||||
                            contentDescription = stringResource(id = R.string.action_select_inverse),
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    if (onShareClicked != null) {
 | 
			
		||||
                        IconButton(onClick = onShareClicked) {
 | 
			
		||||
                            Icon(
 | 
			
		||||
                                imageVector = Icons.Default.Share,
 | 
			
		||||
                                contentDescription = stringResource(id = R.string.action_share),
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (onDownloadClicked != null) {
 | 
			
		||||
                        val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }
 | 
			
		||||
                        Box {
 | 
			
		||||
                            IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) {
 | 
			
		||||
                                Icon(
 | 
			
		||||
                                    imageVector = Icons.Default.Download,
 | 
			
		||||
                                    contentDescription = stringResource(id = R.string.manga_download),
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                            val onDismissRequest = { onDownloadExpanded(false) }
 | 
			
		||||
                            DropdownMenu(
 | 
			
		||||
                                expanded = downloadExpanded,
 | 
			
		||||
                                onDismissRequest = onDismissRequest,
 | 
			
		||||
                            ) {
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_1)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.NEXT_1_CHAPTER)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_5)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.NEXT_5_CHAPTERS)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_10)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.NEXT_10_CHAPTERS)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_custom)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.CUSTOM)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_unread)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.UNREAD_CHAPTERS)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.download_all)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onDownloadClicked(DownloadAction.ALL_CHAPTERS)
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (onEditCategoryClicked != null && onMigrateClicked != null) {
 | 
			
		||||
                        val (moreExpanded, onMoreExpanded) = remember { mutableStateOf(false) }
 | 
			
		||||
                        Box {
 | 
			
		||||
                            IconButton(onClick = { onMoreExpanded(!moreExpanded) }) {
 | 
			
		||||
                                Icon(
 | 
			
		||||
                                    imageVector = Icons.Default.MoreVert,
 | 
			
		||||
                                    contentDescription = stringResource(id = R.string.abc_action_menu_overflow_description),
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                            val onDismissRequest = { onMoreExpanded(false) }
 | 
			
		||||
                            DropdownMenu(
 | 
			
		||||
                                expanded = moreExpanded,
 | 
			
		||||
                                onDismissRequest = onDismissRequest,
 | 
			
		||||
                            ) {
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.action_edit_categories)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onEditCategoryClicked()
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                                DropdownMenuItem(
 | 
			
		||||
                                    text = { Text(text = stringResource(id = R.string.action_migrate)) },
 | 
			
		||||
                                    onClick = {
 | 
			
		||||
                                        onMigrateClicked()
 | 
			
		||||
                                        onDismissRequest()
 | 
			
		||||
                                    },
 | 
			
		||||
                                )
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            // Background handled by parent
 | 
			
		||||
            colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
 | 
			
		||||
                containerColor = Color.Transparent,
 | 
			
		||||
                scrolledContainerColor = Color.Transparent,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if (downloadedOnlyMode) {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = stringResource(id = R.string.label_downloaded_only),
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .background(color = MaterialTheme.colorScheme.tertiary)
 | 
			
		||||
                    .fillMaxWidth()
 | 
			
		||||
                    .padding(4.dp),
 | 
			
		||||
                color = MaterialTheme.colorScheme.onTertiary,
 | 
			
		||||
                textAlign = TextAlign.Center,
 | 
			
		||||
                style = MaterialTheme.typography.labelMedium,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        if (incognitoMode) {
 | 
			
		||||
            Text(
 | 
			
		||||
                text = stringResource(id = R.string.pref_incognito_mode),
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .background(color = MaterialTheme.colorScheme.primary)
 | 
			
		||||
                    .fillMaxWidth()
 | 
			
		||||
                    .padding(4.dp),
 | 
			
		||||
                color = MaterialTheme.colorScheme.onPrimary,
 | 
			
		||||
                textAlign = TextAlign.Center,
 | 
			
		||||
                style = MaterialTheme.typography.labelMedium,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,141 @@
 | 
			
		||||
package eu.kanade.presentation.manga.components
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.material3.TopAppBarScrollBehavior
 | 
			
		||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.layout.Layout
 | 
			
		||||
import androidx.compose.ui.layout.layoutId
 | 
			
		||||
import androidx.compose.ui.layout.onSizeChanged
 | 
			
		||||
import androidx.compose.ui.platform.LocalDensity
 | 
			
		||||
import androidx.compose.ui.unit.Constraints
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga
 | 
			
		||||
import eu.kanade.presentation.manga.DownloadAction
 | 
			
		||||
import kotlin.math.roundToInt
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MangaTopAppBar(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    title: String,
 | 
			
		||||
    author: String?,
 | 
			
		||||
    artist: String?,
 | 
			
		||||
    description: String?,
 | 
			
		||||
    tagsProvider: () -> List<String>?,
 | 
			
		||||
    coverDataProvider: () -> Manga,
 | 
			
		||||
    sourceName: String,
 | 
			
		||||
    isStubSource: Boolean,
 | 
			
		||||
    favorite: Boolean,
 | 
			
		||||
    status: Long,
 | 
			
		||||
    trackingCount: Int,
 | 
			
		||||
    chapterCount: Int?,
 | 
			
		||||
    chapterFiltered: Boolean,
 | 
			
		||||
    incognitoMode: Boolean,
 | 
			
		||||
    downloadedOnlyMode: Boolean,
 | 
			
		||||
    fromSource: Boolean,
 | 
			
		||||
    onBackClicked: () -> Unit,
 | 
			
		||||
    onCoverClick: () -> Unit,
 | 
			
		||||
    onTagClicked: (String) -> Unit,
 | 
			
		||||
    onAddToLibraryClicked: () -> Unit,
 | 
			
		||||
    onWebViewClicked: (() -> Unit)?,
 | 
			
		||||
    onTrackingClicked: (() -> Unit)?,
 | 
			
		||||
    onFilterButtonClicked: () -> Unit,
 | 
			
		||||
    onShareClicked: (() -> Unit)?,
 | 
			
		||||
    onDownloadClicked: ((DownloadAction) -> Unit)?,
 | 
			
		||||
    onEditCategoryClicked: (() -> Unit)?,
 | 
			
		||||
    onMigrateClicked: (() -> Unit)?,
 | 
			
		||||
    doGlobalSearch: (query: String, global: Boolean) -> Unit,
 | 
			
		||||
    scrollBehavior: TopAppBarScrollBehavior?,
 | 
			
		||||
    // For action mode
 | 
			
		||||
    actionModeCounter: Int,
 | 
			
		||||
    onSelectAll: () -> Unit,
 | 
			
		||||
    onInvertSelection: () -> Unit,
 | 
			
		||||
    onSmallAppBarHeightChanged: (Int) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val scrollPercentageProvider = { scrollBehavior?.scrollFraction?.coerceIn(0f, 1f) ?: 0f }
 | 
			
		||||
    val inverseScrollPercentageProvider = { 1f - scrollPercentageProvider() }
 | 
			
		||||
 | 
			
		||||
    Layout(
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        content = {
 | 
			
		||||
            val (smallHeightPx, onSmallHeightPxChanged) = remember { mutableStateOf(0) }
 | 
			
		||||
            Column(modifier = Modifier.layoutId("mangaInfo")) {
 | 
			
		||||
                MangaInfoHeader(
 | 
			
		||||
                    windowWidthSizeClass = WindowWidthSizeClass.Compact,
 | 
			
		||||
                    appBarPadding = with(LocalDensity.current) { smallHeightPx.toDp() },
 | 
			
		||||
                    title = title,
 | 
			
		||||
                    author = author,
 | 
			
		||||
                    artist = artist,
 | 
			
		||||
                    description = description,
 | 
			
		||||
                    tagsProvider = tagsProvider,
 | 
			
		||||
                    sourceName = sourceName,
 | 
			
		||||
                    isStubSource = isStubSource,
 | 
			
		||||
                    coverDataProvider = coverDataProvider,
 | 
			
		||||
                    favorite = favorite,
 | 
			
		||||
                    status = status,
 | 
			
		||||
                    trackingCount = trackingCount,
 | 
			
		||||
                    fromSource = fromSource,
 | 
			
		||||
                    onAddToLibraryClicked = onAddToLibraryClicked,
 | 
			
		||||
                    onWebViewClicked = onWebViewClicked,
 | 
			
		||||
                    onTrackingClicked = onTrackingClicked,
 | 
			
		||||
                    onTagClicked = onTagClicked,
 | 
			
		||||
                    onEditCategory = onEditCategoryClicked,
 | 
			
		||||
                    onCoverClick = onCoverClick,
 | 
			
		||||
                    doSearch = doGlobalSearch,
 | 
			
		||||
                )
 | 
			
		||||
                ChapterHeader(
 | 
			
		||||
                    chapterCount = chapterCount,
 | 
			
		||||
                    isChapterFiltered = chapterFiltered,
 | 
			
		||||
                    onFilterButtonClicked = onFilterButtonClicked,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            MangaSmallAppBar(
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .layoutId("topBar")
 | 
			
		||||
                    .onSizeChanged {
 | 
			
		||||
                        onSmallHeightPxChanged(it.height)
 | 
			
		||||
                        onSmallAppBarHeightChanged(it.height)
 | 
			
		||||
                    },
 | 
			
		||||
                title = title,
 | 
			
		||||
                titleAlphaProvider = { if (actionModeCounter == 0) scrollPercentageProvider() else 1f },
 | 
			
		||||
                incognitoMode = incognitoMode,
 | 
			
		||||
                downloadedOnlyMode = downloadedOnlyMode,
 | 
			
		||||
                onBackClicked = onBackClicked,
 | 
			
		||||
                onShareClicked = onShareClicked,
 | 
			
		||||
                onDownloadClicked = onDownloadClicked,
 | 
			
		||||
                onEditCategoryClicked = onEditCategoryClicked,
 | 
			
		||||
                onMigrateClicked = onMigrateClicked,
 | 
			
		||||
                actionModeCounter = actionModeCounter,
 | 
			
		||||
                onSelectAll = onSelectAll,
 | 
			
		||||
                onInvertSelection = onInvertSelection,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    ) { measurables, constraints ->
 | 
			
		||||
        val mangaInfoPlaceable = measurables
 | 
			
		||||
            .first { it.layoutId == "mangaInfo" }
 | 
			
		||||
            .measure(constraints.copy(maxHeight = Constraints.Infinity))
 | 
			
		||||
        val topBarPlaceable = measurables
 | 
			
		||||
            .first { it.layoutId == "topBar" }
 | 
			
		||||
            .measure(constraints)
 | 
			
		||||
        val mangaInfoHeight = mangaInfoPlaceable.height
 | 
			
		||||
        val topBarHeight = topBarPlaceable.height
 | 
			
		||||
        val mangaInfoSansTopBarHeightPx = mangaInfoHeight - topBarHeight
 | 
			
		||||
        val layoutHeight = topBarHeight +
 | 
			
		||||
            (mangaInfoSansTopBarHeightPx * inverseScrollPercentageProvider()).roundToInt()
 | 
			
		||||
 | 
			
		||||
        layout(constraints.maxWidth, layoutHeight) {
 | 
			
		||||
            val mangaInfoY = (-mangaInfoSansTopBarHeightPx * scrollPercentageProvider()).roundToInt()
 | 
			
		||||
            mangaInfoPlaceable.place(0, mangaInfoY)
 | 
			
		||||
            topBarPlaceable.place(0, 0)
 | 
			
		||||
 | 
			
		||||
            // Update offset limit
 | 
			
		||||
            val offsetLimit = -mangaInfoSansTopBarHeightPx.toFloat()
 | 
			
		||||
            if (scrollBehavior?.state?.offsetLimit != offsetLimit) {
 | 
			
		||||
                scrollBehavior?.state?.offsetLimit = offsetLimit
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,29 @@
 | 
			
		||||
package eu.kanade.presentation.util
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyListState
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.derivedStateOf
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
 | 
			
		||||
fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun LazyListState.isScrollingUp(): Boolean {
 | 
			
		||||
    var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
 | 
			
		||||
    var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
 | 
			
		||||
    return remember {
 | 
			
		||||
        derivedStateOf {
 | 
			
		||||
            if (previousIndex != firstVisibleItemIndex) {
 | 
			
		||||
                previousIndex > firstVisibleItemIndex
 | 
			
		||||
            } else {
 | 
			
		||||
                previousScrollOffset >= firstVisibleItemScrollOffset
 | 
			
		||||
            }.also {
 | 
			
		||||
                previousIndex = firstVisibleItemIndex
 | 
			
		||||
                previousScrollOffset = firstVisibleItemScrollOffset
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }.value
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,158 @@
 | 
			
		||||
/*
 | 
			
		||||
 * Copyright 2021 The Android Open Source Project
 | 
			
		||||
 *
 | 
			
		||||
 * Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 * you may not use this file except in compliance with the License.
 | 
			
		||||
 * You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 * Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 * distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 * See the License for the specific language governing permissions and
 | 
			
		||||
 * limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package eu.kanade.presentation.util
 | 
			
		||||
 | 
			
		||||
import androidx.compose.animation.core.AnimationState
 | 
			
		||||
import androidx.compose.animation.core.DecayAnimationSpec
 | 
			
		||||
import androidx.compose.animation.core.animateDecay
 | 
			
		||||
import androidx.compose.material3.TopAppBarScrollBehavior
 | 
			
		||||
import androidx.compose.material3.TopAppBarScrollState
 | 
			
		||||
import androidx.compose.ui.geometry.Offset
 | 
			
		||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 | 
			
		||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 | 
			
		||||
import androidx.compose.ui.unit.Velocity
 | 
			
		||||
import kotlin.math.abs
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and height of a top
 | 
			
		||||
 * app bar.
 | 
			
		||||
 *
 | 
			
		||||
 * A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when
 | 
			
		||||
 * the nested content is pulled up, and will expand back the collapsed area when the content is
 | 
			
		||||
 * pulled all the way down.
 | 
			
		||||
 *
 | 
			
		||||
 * @param decayAnimationSpec a [DecayAnimationSpec] that will be used by the top app bar motion
 | 
			
		||||
 * when the user flings the content. Preferably, this should match the animation spec used by the
 | 
			
		||||
 * scrollable content. See also [androidx.compose.animation.rememberSplineBasedDecay] for a
 | 
			
		||||
 * default [DecayAnimationSpec] that can be used with this behavior.
 | 
			
		||||
 * @param canScroll a callback used to determine whether scroll events are to be
 | 
			
		||||
 * handled by this [ExitUntilCollapsedScrollBehavior]
 | 
			
		||||
 */
 | 
			
		||||
class ExitUntilCollapsedScrollBehavior(
 | 
			
		||||
    override val state: TopAppBarScrollState,
 | 
			
		||||
    val decayAnimationSpec: DecayAnimationSpec<Float>,
 | 
			
		||||
    val canScroll: () -> Boolean = { true },
 | 
			
		||||
) : TopAppBarScrollBehavior {
 | 
			
		||||
    override val scrollFraction: Float
 | 
			
		||||
        get() = if (state.offsetLimit != 0f) state.offset / state.offsetLimit else 0f
 | 
			
		||||
    override var nestedScrollConnection =
 | 
			
		||||
        object : NestedScrollConnection {
 | 
			
		||||
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
 | 
			
		||||
                // Don't intercept if scrolling down.
 | 
			
		||||
                if (!canScroll() || available.y > 0f) return Offset.Zero
 | 
			
		||||
 | 
			
		||||
                val newOffset = (state.offset + available.y)
 | 
			
		||||
                val coerced =
 | 
			
		||||
                    newOffset.coerceIn(minimumValue = state.offsetLimit, maximumValue = 0f)
 | 
			
		||||
                return if (newOffset == coerced) {
 | 
			
		||||
                    // Nothing coerced, meaning we're in the middle of top app bar collapse or
 | 
			
		||||
                    // expand.
 | 
			
		||||
                    state.offset = coerced
 | 
			
		||||
                    // Consume only the scroll on the Y axis.
 | 
			
		||||
                    available.copy(x = 0f)
 | 
			
		||||
                } else {
 | 
			
		||||
                    Offset.Zero
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            override fun onPostScroll(
 | 
			
		||||
                consumed: Offset,
 | 
			
		||||
                available: Offset,
 | 
			
		||||
                source: NestedScrollSource,
 | 
			
		||||
            ): Offset {
 | 
			
		||||
                if (!canScroll()) return Offset.Zero
 | 
			
		||||
                state.contentOffset += consumed.y
 | 
			
		||||
 | 
			
		||||
                if (available.y < 0f || consumed.y < 0f) {
 | 
			
		||||
                    // When scrolling up, just update the state's offset.
 | 
			
		||||
                    val oldOffset = state.offset
 | 
			
		||||
                    state.offset = (state.offset + consumed.y).coerceIn(
 | 
			
		||||
                        minimumValue = state.offsetLimit,
 | 
			
		||||
                        maximumValue = 0f,
 | 
			
		||||
                    )
 | 
			
		||||
                    return Offset(0f, state.offset - oldOffset)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (consumed.y == 0f && available.y > 0) {
 | 
			
		||||
                    // Reset the total offset to zero when scrolling all the way down. This will
 | 
			
		||||
                    // eliminate some float precision inaccuracies.
 | 
			
		||||
                    state.contentOffset = 0f
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (available.y > 0f) {
 | 
			
		||||
                    // Adjust the offset in case the consumed delta Y is less than what was recorded
 | 
			
		||||
                    // as available delta Y in the pre-scroll.
 | 
			
		||||
                    val oldOffset = state.offset
 | 
			
		||||
                    state.offset = (state.offset + available.y).coerceIn(
 | 
			
		||||
                        minimumValue = state.offsetLimit,
 | 
			
		||||
                        maximumValue = 0f,
 | 
			
		||||
                    )
 | 
			
		||||
                    return Offset(0f, state.offset - oldOffset)
 | 
			
		||||
                }
 | 
			
		||||
                return Offset.Zero
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
 | 
			
		||||
                val result = super.onPostFling(consumed, available)
 | 
			
		||||
                if ((available.y < 0f && state.contentOffset == 0f) ||
 | 
			
		||||
                    (available.y > 0f && state.offset < 0f)
 | 
			
		||||
                ) {
 | 
			
		||||
                    return result +
 | 
			
		||||
                        onTopBarFling(
 | 
			
		||||
                            scrollBehavior = this@ExitUntilCollapsedScrollBehavior,
 | 
			
		||||
                            initialVelocity = available.y,
 | 
			
		||||
                            decayAnimationSpec = decayAnimationSpec,
 | 
			
		||||
                        )
 | 
			
		||||
                }
 | 
			
		||||
                return result
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Tachiyomi: Remove snap behavior
 | 
			
		||||
 */
 | 
			
		||||
private suspend fun onTopBarFling(
 | 
			
		||||
    scrollBehavior: TopAppBarScrollBehavior,
 | 
			
		||||
    initialVelocity: Float,
 | 
			
		||||
    decayAnimationSpec: DecayAnimationSpec<Float>,
 | 
			
		||||
): Velocity {
 | 
			
		||||
    if (abs(initialVelocity) > 1f) {
 | 
			
		||||
        var remainingVelocity = initialVelocity
 | 
			
		||||
        var lastValue = 0f
 | 
			
		||||
        AnimationState(
 | 
			
		||||
            initialValue = 0f,
 | 
			
		||||
            initialVelocity = initialVelocity,
 | 
			
		||||
        )
 | 
			
		||||
            .animateDecay(decayAnimationSpec) {
 | 
			
		||||
                val delta = value - lastValue
 | 
			
		||||
                val initialOffset = scrollBehavior.state.offset
 | 
			
		||||
                scrollBehavior.state.offset =
 | 
			
		||||
                    (initialOffset + delta).coerceIn(
 | 
			
		||||
                        minimumValue = scrollBehavior.state.offsetLimit,
 | 
			
		||||
                        maximumValue = 0f,
 | 
			
		||||
                    )
 | 
			
		||||
                val consumed = abs(initialOffset - scrollBehavior.state.offset)
 | 
			
		||||
                lastValue = value
 | 
			
		||||
                remainingVelocity = this.velocity
 | 
			
		||||
                // avoid rounding errors and stop if anything is unconsumed
 | 
			
		||||
                if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
 | 
			
		||||
            }
 | 
			
		||||
        return Velocity(0f, remainingVelocity)
 | 
			
		||||
    }
 | 
			
		||||
    return Velocity.Zero
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
package eu.kanade.presentation.util
 | 
			
		||||
 | 
			
		||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.ReadOnlyComposable
 | 
			
		||||
import androidx.compose.ui.platform.LocalConfiguration
 | 
			
		||||
import androidx.compose.ui.unit.Dp
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
@ReadOnlyComposable
 | 
			
		||||
fun calculateWindowWidthSizeClass(): WindowWidthSizeClass {
 | 
			
		||||
    val configuration = LocalConfiguration.current
 | 
			
		||||
    return fromWidth(configuration.smallestScreenWidthDp.dp)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private fun fromWidth(width: Dp): WindowWidthSizeClass {
 | 
			
		||||
    require(width >= 0.dp) { "Width must not be negative" }
 | 
			
		||||
    return when {
 | 
			
		||||
        width < 720.dp -> WindowWidthSizeClass.Compact // Was 600
 | 
			
		||||
        width < 840.dp -> WindowWidthSizeClass.Medium
 | 
			
		||||
        else -> WindowWidthSizeClass.Expanded
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SChapter
 | 
			
		||||
import java.io.Serializable
 | 
			
		||||
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
 | 
			
		||||
 | 
			
		||||
interface Chapter : SChapter, Serializable {
 | 
			
		||||
 | 
			
		||||
@@ -29,3 +30,21 @@ interface Chapter : SChapter, Serializable {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun Chapter.toDomainChapter(): DomainChapter? {
 | 
			
		||||
    if (id == null || manga_id == null) return null
 | 
			
		||||
    return DomainChapter(
 | 
			
		||||
        id = id!!,
 | 
			
		||||
        mangaId = manga_id!!,
 | 
			
		||||
        read = read,
 | 
			
		||||
        bookmark = bookmark,
 | 
			
		||||
        lastPageRead = last_page_read.toLong(),
 | 
			
		||||
        dateFetch = date_fetch,
 | 
			
		||||
        sourceOrder = source_order.toLong(),
 | 
			
		||||
        url = url,
 | 
			
		||||
        name = name,
 | 
			
		||||
        dateUpload = date_upload,
 | 
			
		||||
        chapterNumber = chapter_number,
 | 
			
		||||
        scanlator = scanlator,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,4 +12,24 @@ class LibraryManga : MangaImpl() {
 | 
			
		||||
        get() = readCount > 0
 | 
			
		||||
 | 
			
		||||
    var category: Int = 0
 | 
			
		||||
 | 
			
		||||
    override fun equals(other: Any?): Boolean {
 | 
			
		||||
        if (this === other) return true
 | 
			
		||||
        if (other !is LibraryManga) return false
 | 
			
		||||
        if (!super.equals(other)) return false
 | 
			
		||||
 | 
			
		||||
        if (unreadCount != other.unreadCount) return false
 | 
			
		||||
        if (readCount != other.readCount) return false
 | 
			
		||||
        if (category != other.category) return false
 | 
			
		||||
 | 
			
		||||
        return true
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun hashCode(): Int {
 | 
			
		||||
        var result = super.hashCode()
 | 
			
		||||
        result = 31 * result + unreadCount
 | 
			
		||||
        result = 31 * result + readCount
 | 
			
		||||
        result = 31 * result + category
 | 
			
		||||
        return result
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -121,3 +121,5 @@ fun Source.getNameForMangaInfo(): String {
 | 
			
		||||
        else -> toString()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource
 | 
			
		||||
 
 | 
			
		||||
@@ -57,6 +57,33 @@ open class Page(
 | 
			
		||||
        statusCallback = f
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun equals(other: Any?): Boolean {
 | 
			
		||||
        if (this === other) return true
 | 
			
		||||
        if (other !is Page) return false
 | 
			
		||||
 | 
			
		||||
        if (index != other.index) return false
 | 
			
		||||
        if (url != other.url) return false
 | 
			
		||||
        if (imageUrl != other.imageUrl) return false
 | 
			
		||||
        if (number != other.number) return false
 | 
			
		||||
        if (status != other.status) return false
 | 
			
		||||
        if (progress != other.progress) return false
 | 
			
		||||
        if (statusSubject != other.statusSubject) return false
 | 
			
		||||
        if (statusCallback != other.statusCallback) return false
 | 
			
		||||
 | 
			
		||||
        return true
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun hashCode(): Int {
 | 
			
		||||
        var result = index
 | 
			
		||||
        result = 31 * result + url.hashCode()
 | 
			
		||||
        result = 31 * result + (imageUrl?.hashCode() ?: 0)
 | 
			
		||||
        result = 31 * result + status
 | 
			
		||||
        result = 31 * result + progress
 | 
			
		||||
        result = 31 * result + (statusSubject?.hashCode() ?: 0)
 | 
			
		||||
        result = 31 * result + (statusCallback?.hashCode() ?: 0)
 | 
			
		||||
        return result
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        const val QUEUE = 0
 | 
			
		||||
        const val LOAD_PAGE = 1
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,7 @@ class SearchController(
 | 
			
		||||
        binding.progress.isVisible = isReplacingManga
 | 
			
		||||
        if (!isReplacingManga) {
 | 
			
		||||
            router.popController(this)
 | 
			
		||||
            if (newManga != null) {
 | 
			
		||||
            if (newManga?.id != null) {
 | 
			
		||||
                val newMangaController = RouterTransaction.with(MangaController(newManga.id!!))
 | 
			
		||||
                if (router.backstack.lastOrNull()?.controller is MangaController) {
 | 
			
		||||
                    // Replace old MangaController
 | 
			
		||||
 
 | 
			
		||||
@@ -1,40 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.library
 | 
			
		||||
 | 
			
		||||
import android.app.Dialog
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import com.bluelinelabs.conductor.Controller
 | 
			
		||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
 | 
			
		||||
 | 
			
		||||
class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
 | 
			
		||||
    DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
 | 
			
		||||
 | 
			
		||||
    private lateinit var manga: Manga
 | 
			
		||||
 | 
			
		||||
    constructor(target: T, manga: Manga) : this() {
 | 
			
		||||
        targetController = target
 | 
			
		||||
        this.manga = manga
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Suppress("DEPRECATION")
 | 
			
		||||
    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
 | 
			
		||||
        return MaterialAlertDialogBuilder(activity!!)
 | 
			
		||||
            .setTitle(R.string.action_edit_cover)
 | 
			
		||||
            .setPositiveButton(R.string.action_edit) { _, _ ->
 | 
			
		||||
                (targetController as? Listener)?.openMangaCoverPicker(manga)
 | 
			
		||||
            }
 | 
			
		||||
            .setNegativeButton(android.R.string.cancel, null)
 | 
			
		||||
            .setNeutralButton(R.string.action_delete) { _, _ ->
 | 
			
		||||
                (targetController as? Listener)?.deleteMangaCover(manga)
 | 
			
		||||
            }
 | 
			
		||||
            .create()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    interface Listener {
 | 
			
		||||
        fun deleteMangaCover(manga: Manga)
 | 
			
		||||
 | 
			
		||||
        fun openMangaCoverPicker(manga: Manga)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -420,7 +420,7 @@ class MainActivity : BaseActivity() {
 | 
			
		||||
            SHORTCUT_MANGA -> {
 | 
			
		||||
                val extras = intent.extras ?: return false
 | 
			
		||||
                val fgController = router.backstack.lastOrNull()?.controller as? MangaController
 | 
			
		||||
                if (fgController?.manga?.id != extras.getLong(MangaController.MANGA_EXTRA)) {
 | 
			
		||||
                if (fgController?.mangaId != extras.getLong(MangaController.MANGA_EXTRA)) {
 | 
			
		||||
                    router.popToRoot()
 | 
			
		||||
                    setSelectedNavItem(R.id.nav_library)
 | 
			
		||||
                    router.pushController(RouterTransaction.with(MangaController(extras)))
 | 
			
		||||
@@ -601,6 +601,9 @@ class MainActivity : BaseActivity() {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val isFullComposeController = internalTo is FullComposeController<*>
 | 
			
		||||
        binding.appbar.isVisible = !isFullComposeController
 | 
			
		||||
        binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
 | 
			
		||||
 | 
			
		||||
        if (!isTablet()) {
 | 
			
		||||
            // Save lift state
 | 
			
		||||
            if (isPush) {
 | 
			
		||||
@@ -623,17 +626,6 @@ class MainActivity : BaseActivity() {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController
 | 
			
		||||
 | 
			
		||||
            binding.appbar.isVisible = !isFullComposeController
 | 
			
		||||
            binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
 | 
			
		||||
 | 
			
		||||
            // TODO: Remove when MangaController is full compose
 | 
			
		||||
            if (!isFullComposeController) {
 | 
			
		||||
                binding.appbar.isTransparentWhenNotLifted = internalTo is MangaController
 | 
			
		||||
                binding.controllerContainer.overlapHeader = internalTo is MangaController
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            binding.appbar.isVisible = !isFullComposeController
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,127 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.chapter
 | 
			
		||||
 | 
			
		||||
import android.text.SpannableStringBuilder
 | 
			
		||||
import android.view.View
 | 
			
		||||
import androidx.core.text.buildSpannedString
 | 
			
		||||
import androidx.core.text.color
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Chapter
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.ChaptersItemBinding
 | 
			
		||||
import eu.kanade.tachiyomi.source.LocalSource
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
 | 
			
		||||
import java.util.Date
 | 
			
		||||
 | 
			
		||||
class ChapterHolder(
 | 
			
		||||
    view: View,
 | 
			
		||||
    private val adapter: ChaptersAdapter,
 | 
			
		||||
) : BaseChapterHolder(view, adapter) {
 | 
			
		||||
 | 
			
		||||
    private val binding = ChaptersItemBinding.bind(view)
 | 
			
		||||
 | 
			
		||||
    init {
 | 
			
		||||
        binding.download.listener = downloadActionListener
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun bind(item: ChapterItem, manga: Manga) {
 | 
			
		||||
        val chapter = item.chapter
 | 
			
		||||
 | 
			
		||||
        binding.chapterTitle.text = when (manga.displayMode) {
 | 
			
		||||
            Manga.CHAPTER_DISPLAY_NUMBER -> {
 | 
			
		||||
                val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
 | 
			
		||||
                itemView.context.getString(R.string.display_mode_chapter, number)
 | 
			
		||||
            }
 | 
			
		||||
            else -> chapter.name
 | 
			
		||||
            // TODO: show cleaned name consistently around the app
 | 
			
		||||
            // else -> cleanChapterName(chapter, manga)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set correct text color
 | 
			
		||||
        val chapterTitleColor = when {
 | 
			
		||||
            chapter.read -> adapter.readColor
 | 
			
		||||
            chapter.bookmark -> adapter.bookmarkedColor
 | 
			
		||||
            else -> adapter.unreadColor
 | 
			
		||||
        }
 | 
			
		||||
        binding.chapterTitle.setTextColor(chapterTitleColor)
 | 
			
		||||
 | 
			
		||||
        val chapterDescriptionColor = when {
 | 
			
		||||
            chapter.read -> adapter.readColor
 | 
			
		||||
            chapter.bookmark -> adapter.bookmarkedColor
 | 
			
		||||
            else -> adapter.unreadColorSecondary
 | 
			
		||||
        }
 | 
			
		||||
        binding.chapterDescription.setTextColor(chapterDescriptionColor)
 | 
			
		||||
 | 
			
		||||
        binding.bookmarkIcon.isVisible = chapter.bookmark
 | 
			
		||||
 | 
			
		||||
        val descriptions = mutableListOf<CharSequence>()
 | 
			
		||||
 | 
			
		||||
        if (chapter.date_upload > 0) {
 | 
			
		||||
            descriptions.add(Date(chapter.date_upload).toRelativeString(itemView.context, adapter.relativeTime, adapter.dateFormat))
 | 
			
		||||
        }
 | 
			
		||||
        if (!chapter.read && chapter.last_page_read > 0) {
 | 
			
		||||
            val lastPageRead = buildSpannedString {
 | 
			
		||||
                color(adapter.readColor) {
 | 
			
		||||
                    append(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1))
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            descriptions.add(lastPageRead)
 | 
			
		||||
        }
 | 
			
		||||
        if (!chapter.scanlator.isNullOrBlank()) {
 | 
			
		||||
            descriptions.add(chapter.scanlator!!)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (descriptions.isNotEmpty()) {
 | 
			
		||||
            binding.chapterDescription.text = descriptions.joinTo(SpannableStringBuilder(), " • ")
 | 
			
		||||
        } else {
 | 
			
		||||
            binding.chapterDescription.text = ""
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        binding.download.isVisible = item.manga.source != LocalSource.ID
 | 
			
		||||
        binding.download.setState(item.status, item.progress)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun cleanChapterName(chapter: Chapter, manga: Manga): String {
 | 
			
		||||
        return chapter.name
 | 
			
		||||
            .trim()
 | 
			
		||||
            .removePrefix(manga.title)
 | 
			
		||||
            .trim(*CHAPTER_TRIM_CHARS)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private val CHAPTER_TRIM_CHARS = arrayOf(
 | 
			
		||||
    // Whitespace
 | 
			
		||||
    ' ',
 | 
			
		||||
    '\u0009',
 | 
			
		||||
    '\u000A',
 | 
			
		||||
    '\u000B',
 | 
			
		||||
    '\u000C',
 | 
			
		||||
    '\u000D',
 | 
			
		||||
    '\u0020',
 | 
			
		||||
    '\u0085',
 | 
			
		||||
    '\u00A0',
 | 
			
		||||
    '\u1680',
 | 
			
		||||
    '\u2000',
 | 
			
		||||
    '\u2001',
 | 
			
		||||
    '\u2002',
 | 
			
		||||
    '\u2003',
 | 
			
		||||
    '\u2004',
 | 
			
		||||
    '\u2005',
 | 
			
		||||
    '\u2006',
 | 
			
		||||
    '\u2007',
 | 
			
		||||
    '\u2008',
 | 
			
		||||
    '\u2009',
 | 
			
		||||
    '\u200A',
 | 
			
		||||
    '\u2028',
 | 
			
		||||
    '\u2029',
 | 
			
		||||
    '\u202F',
 | 
			
		||||
    '\u205F',
 | 
			
		||||
    '\u3000',
 | 
			
		||||
 | 
			
		||||
    // Separators
 | 
			
		||||
    '-',
 | 
			
		||||
    '_',
 | 
			
		||||
    ',',
 | 
			
		||||
    ':',
 | 
			
		||||
).toCharArray()
 | 
			
		||||
@@ -1,33 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.chapter
 | 
			
		||||
 | 
			
		||||
import android.view.View
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import eu.davidea.flexibleadapter.FlexibleAdapter
 | 
			
		||||
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
 | 
			
		||||
import eu.davidea.flexibleadapter.items.IFlexible
 | 
			
		||||
import eu.davidea.viewholders.FlexibleViewHolder
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Chapter
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
 | 
			
		||||
 | 
			
		||||
class ChapterItem(chapter: Chapter, val manga: Manga) :
 | 
			
		||||
    BaseChapterItem<ChapterHolder, AbstractHeaderItem<FlexibleViewHolder>>(chapter) {
 | 
			
		||||
 | 
			
		||||
    override fun getLayoutRes(): Int {
 | 
			
		||||
        return R.layout.chapters_item
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
 | 
			
		||||
        return ChapterHolder(view, adapter as ChaptersAdapter)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun bindViewHolder(
 | 
			
		||||
        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
 | 
			
		||||
        holder: ChapterHolder,
 | 
			
		||||
        position: Int,
 | 
			
		||||
        payloads: List<Any?>?,
 | 
			
		||||
    ) {
 | 
			
		||||
        holder.bind(this, manga)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.chapter
 | 
			
		||||
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.getResourceColor
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
import java.text.DateFormat
 | 
			
		||||
import java.text.DecimalFormat
 | 
			
		||||
import java.text.DecimalFormatSymbols
 | 
			
		||||
 | 
			
		||||
class ChaptersAdapter(
 | 
			
		||||
    controller: MangaController,
 | 
			
		||||
    context: Context,
 | 
			
		||||
) : BaseChaptersAdapter<ChapterItem>(controller) {
 | 
			
		||||
 | 
			
		||||
    private val preferences: PreferencesHelper by injectLazy()
 | 
			
		||||
 | 
			
		||||
    var items: List<ChapterItem> = emptyList()
 | 
			
		||||
 | 
			
		||||
    val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
 | 
			
		||||
    val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
 | 
			
		||||
    val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
 | 
			
		||||
 | 
			
		||||
    val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
 | 
			
		||||
 | 
			
		||||
    val decimalFormat = DecimalFormat(
 | 
			
		||||
        "#.###",
 | 
			
		||||
        DecimalFormatSymbols()
 | 
			
		||||
            .apply { decimalSeparator = '.' },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    val relativeTime: Int = preferences.relativeTime().get()
 | 
			
		||||
    val dateFormat: DateFormat = preferences.dateFormat()
 | 
			
		||||
 | 
			
		||||
    override fun updateDataSet(items: List<ChapterItem>?) {
 | 
			
		||||
        this.items = items ?: emptyList()
 | 
			
		||||
        super.updateDataSet(items)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun indexOf(item: ChapterItem): Int {
 | 
			
		||||
        return items.indexOf(item)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,10 +7,11 @@ import android.view.View
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import com.bluelinelabs.conductor.Router
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga
 | 
			
		||||
import eu.kanade.domain.manga.model.toDbManga
 | 
			
		||||
import eu.kanade.domain.manga.model.toTriStateGroupState
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.toDomainManga
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
 | 
			
		||||
import eu.kanade.tachiyomi.util.view.popupMenu
 | 
			
		||||
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
 | 
			
		||||
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
 | 
			
		||||
@@ -18,6 +19,9 @@ import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
 | 
			
		||||
import kotlinx.coroutines.CoroutineScope
 | 
			
		||||
import kotlinx.coroutines.MainScope
 | 
			
		||||
import kotlinx.coroutines.cancel
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.flow.filterIsInstance
 | 
			
		||||
import kotlinx.coroutines.launch
 | 
			
		||||
 | 
			
		||||
class ChaptersSettingsSheet(
 | 
			
		||||
    private val router: Router,
 | 
			
		||||
@@ -28,7 +32,7 @@ class ChaptersSettingsSheet(
 | 
			
		||||
 | 
			
		||||
    private var manga: Manga? = null
 | 
			
		||||
 | 
			
		||||
    val filters = Filter(context)
 | 
			
		||||
    private val filters = Filter(context)
 | 
			
		||||
    private val sort = Sort(context)
 | 
			
		||||
    private val display = Display(context)
 | 
			
		||||
 | 
			
		||||
@@ -42,8 +46,14 @@ class ChaptersSettingsSheet(
 | 
			
		||||
    override fun onAttachedToWindow() {
 | 
			
		||||
        super.onAttachedToWindow()
 | 
			
		||||
        scope = MainScope()
 | 
			
		||||
        // TODO: Listen to changes
 | 
			
		||||
        updateManga()
 | 
			
		||||
        scope.launch {
 | 
			
		||||
            presenter.state
 | 
			
		||||
                .filterIsInstance<MangaScreenState.Success>()
 | 
			
		||||
                .collectLatest {
 | 
			
		||||
                    manga = it.manga
 | 
			
		||||
                    getTabViews().forEach { settings -> (settings as Settings).updateView() }
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun onDetachedFromWindow() {
 | 
			
		||||
@@ -63,17 +73,13 @@ class ChaptersSettingsSheet(
 | 
			
		||||
        R.string.action_display,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    private fun updateManga() {
 | 
			
		||||
        manga = presenter.manga.toDomainManga()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun showPopupMenu(view: View) {
 | 
			
		||||
        view.popupMenu(
 | 
			
		||||
            menuRes = R.menu.default_chapter_filter,
 | 
			
		||||
            onMenuItemClick = {
 | 
			
		||||
                when (itemId) {
 | 
			
		||||
                    R.id.set_as_default -> {
 | 
			
		||||
                        SetChapterSettingsDialog(presenter.manga).showDialog(router)
 | 
			
		||||
                        SetChapterSettingsDialog(presenter.manga!!.toDbManga()).showDialog(router)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
@@ -144,10 +150,6 @@ class ChaptersSettingsSheet(
 | 
			
		||||
                    bookmarked -> presenter.setBookmarkedFilter(newState)
 | 
			
		||||
                    else -> {}
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // TODO: Remove
 | 
			
		||||
                updateManga()
 | 
			
		||||
                updateView()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -202,16 +204,11 @@ class ChaptersSettingsSheet(
 | 
			
		||||
 | 
			
		||||
            override fun onItemClicked(item: Item) {
 | 
			
		||||
                when (item) {
 | 
			
		||||
                    source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE.toInt())
 | 
			
		||||
                    chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER.toInt())
 | 
			
		||||
                    uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE.toInt())
 | 
			
		||||
                    source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE)
 | 
			
		||||
                    chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER)
 | 
			
		||||
                    uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE)
 | 
			
		||||
                    else -> throw Exception("Unknown sorting")
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // TODO: Remove
 | 
			
		||||
                presenter.reverseSortOrder()
 | 
			
		||||
                updateManga()
 | 
			
		||||
                updateView()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -257,14 +254,10 @@ class ChaptersSettingsSheet(
 | 
			
		||||
                if (item.checked) return
 | 
			
		||||
 | 
			
		||||
                when (item) {
 | 
			
		||||
                    displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME.toInt())
 | 
			
		||||
                    displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER.toInt())
 | 
			
		||||
                    displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME)
 | 
			
		||||
                    displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER)
 | 
			
		||||
                    else -> throw NotImplementedError("Unknown display mode")
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // TODO: Remove
 | 
			
		||||
                updateManga()
 | 
			
		||||
                updateView()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,30 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.chapter
 | 
			
		||||
 | 
			
		||||
import android.app.Dialog
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import com.bluelinelabs.conductor.Controller
 | 
			
		||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
 | 
			
		||||
 | 
			
		||||
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
 | 
			
		||||
        where T : Controller, T : DeleteChaptersDialog.Listener {
 | 
			
		||||
 | 
			
		||||
    constructor(target: T) : this() {
 | 
			
		||||
        targetController = target
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
 | 
			
		||||
        return MaterialAlertDialogBuilder(activity!!)
 | 
			
		||||
            .setMessage(R.string.confirm_delete_chapters)
 | 
			
		||||
            .setPositiveButton(android.R.string.ok) { _, _ ->
 | 
			
		||||
                (targetController as? Listener)?.deleteChapters()
 | 
			
		||||
            }
 | 
			
		||||
            .setNegativeButton(android.R.string.cancel, null)
 | 
			
		||||
            .create()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    interface Listener {
 | 
			
		||||
        fun deleteChapters()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,69 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.chapter
 | 
			
		||||
 | 
			
		||||
import android.view.LayoutInflater
 | 
			
		||||
import android.view.View
 | 
			
		||||
import android.view.ViewGroup
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.MangaChaptersHeaderBinding
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaController
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.getResourceColor
 | 
			
		||||
import kotlinx.coroutines.flow.launchIn
 | 
			
		||||
import kotlinx.coroutines.flow.merge
 | 
			
		||||
import kotlinx.coroutines.flow.onEach
 | 
			
		||||
import reactivecircus.flowbinding.android.view.clicks
 | 
			
		||||
 | 
			
		||||
class MangaChaptersHeaderAdapter(
 | 
			
		||||
    private val controller: MangaController,
 | 
			
		||||
) :
 | 
			
		||||
    RecyclerView.Adapter<MangaChaptersHeaderAdapter.HeaderViewHolder>() {
 | 
			
		||||
 | 
			
		||||
    private var numChapters: Int? = null
 | 
			
		||||
    private var hasActiveFilters: Boolean = false
 | 
			
		||||
 | 
			
		||||
    private lateinit var binding: MangaChaptersHeaderBinding
 | 
			
		||||
 | 
			
		||||
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
 | 
			
		||||
        binding = MangaChaptersHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
 | 
			
		||||
        return HeaderViewHolder(binding.root)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getItemCount(): Int = 1
 | 
			
		||||
 | 
			
		||||
    override fun getItemId(position: Int): Long = hashCode().toLong()
 | 
			
		||||
 | 
			
		||||
    override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
 | 
			
		||||
        holder.bind()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun setNumChapters(numChapters: Int) {
 | 
			
		||||
        this.numChapters = numChapters
 | 
			
		||||
        notifyItemChanged(0, this)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun setHasActiveFilters(hasActiveFilters: Boolean) {
 | 
			
		||||
        this.hasActiveFilters = hasActiveFilters
 | 
			
		||||
        notifyItemChanged(0, this)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
 | 
			
		||||
        fun bind() {
 | 
			
		||||
            binding.chaptersLabel.text = if (numChapters == null) {
 | 
			
		||||
                view.context.getString(R.string.chapters)
 | 
			
		||||
            } else {
 | 
			
		||||
                view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numChapters!!, numChapters)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            val filterColor = if (hasActiveFilters) {
 | 
			
		||||
                view.context.getResourceColor(R.attr.colorFilterActive)
 | 
			
		||||
            } else {
 | 
			
		||||
                view.context.getResourceColor(R.attr.colorOnBackground)
 | 
			
		||||
            }
 | 
			
		||||
            binding.btnChaptersFilter.drawable.setTint(filterColor)
 | 
			
		||||
 | 
			
		||||
            merge(view.clicks(), binding.btnChaptersFilter.clicks())
 | 
			
		||||
                .onEach { controller.showSettingsSheet() }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,276 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.manga.info
 | 
			
		||||
 | 
			
		||||
import android.view.LayoutInflater
 | 
			
		||||
import android.view.View
 | 
			
		||||
import android.view.ViewGroup
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import androidx.core.view.updateLayoutParams
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.TrackManager
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
 | 
			
		||||
import eu.kanade.tachiyomi.source.Source
 | 
			
		||||
import eu.kanade.tachiyomi.source.SourceManager
 | 
			
		||||
import eu.kanade.tachiyomi.source.getNameForMangaInfo
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SManga
 | 
			
		||||
import eu.kanade.tachiyomi.source.online.HttpSource
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
 | 
			
		||||
import eu.kanade.tachiyomi.ui.manga.MangaController
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
 | 
			
		||||
import eu.kanade.tachiyomi.util.view.loadAutoPause
 | 
			
		||||
import kotlinx.coroutines.flow.launchIn
 | 
			
		||||
import kotlinx.coroutines.flow.onEach
 | 
			
		||||
import reactivecircus.flowbinding.android.view.clicks
 | 
			
		||||
import reactivecircus.flowbinding.android.view.longClicks
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
 | 
			
		||||
class MangaInfoHeaderAdapter(
 | 
			
		||||
    private val controller: MangaController,
 | 
			
		||||
    private val fromSource: Boolean,
 | 
			
		||||
    private val isTablet: Boolean,
 | 
			
		||||
) :
 | 
			
		||||
    RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
 | 
			
		||||
 | 
			
		||||
    private val trackManager: TrackManager by injectLazy()
 | 
			
		||||
    private val preferences: PreferencesHelper by injectLazy()
 | 
			
		||||
    private val sourceManager: SourceManager by injectLazy()
 | 
			
		||||
 | 
			
		||||
    private var manga: Manga = controller.presenter.manga
 | 
			
		||||
    private var source: Source = controller.presenter.source
 | 
			
		||||
    private var trackCount: Int = 0
 | 
			
		||||
 | 
			
		||||
    private lateinit var binding: MangaInfoHeaderBinding
 | 
			
		||||
 | 
			
		||||
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
 | 
			
		||||
        binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
 | 
			
		||||
        updateCoverPosition()
 | 
			
		||||
 | 
			
		||||
        // Expand manga info if navigated from source listing or explicitly set to
 | 
			
		||||
        // (e.g. on tablets)
 | 
			
		||||
        binding.mangaSummarySection.expanded = fromSource || isTablet
 | 
			
		||||
 | 
			
		||||
        return HeaderViewHolder(binding.root)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getItemCount(): Int = 1
 | 
			
		||||
 | 
			
		||||
    override fun getItemId(position: Int): Long = hashCode().toLong()
 | 
			
		||||
 | 
			
		||||
    override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
 | 
			
		||||
        holder.bind()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the view with manga information.
 | 
			
		||||
     *
 | 
			
		||||
     * @param manga manga object containing information about manga.
 | 
			
		||||
     * @param source the source of the manga.
 | 
			
		||||
     */
 | 
			
		||||
    fun update(manga: Manga, source: Source) {
 | 
			
		||||
        this.manga = manga
 | 
			
		||||
        this.source = source
 | 
			
		||||
        update()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun update() {
 | 
			
		||||
        notifyItemChanged(0, this)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun setTrackingCount(trackCount: Int) {
 | 
			
		||||
        this.trackCount = trackCount
 | 
			
		||||
        update()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun updateCoverPosition() {
 | 
			
		||||
        if (isTablet) return
 | 
			
		||||
        val appBarHeight = controller.getMainAppBarHeight()
 | 
			
		||||
        binding.mangaCover.updateLayoutParams<ViewGroup.MarginLayoutParams> {
 | 
			
		||||
            topMargin += appBarHeight
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
 | 
			
		||||
        fun bind() {
 | 
			
		||||
            // For rounded corners
 | 
			
		||||
            binding.mangaCover.clipToOutline = true
 | 
			
		||||
 | 
			
		||||
            binding.btnFavorite.clicks()
 | 
			
		||||
                .onEach { controller.onFavoriteClick() }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            if (controller.presenter.manga.favorite) {
 | 
			
		||||
                binding.btnFavorite.longClicks()
 | 
			
		||||
                    .onEach { controller.onCategoriesClick() }
 | 
			
		||||
                    .launchIn(controller.viewScope)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            with(binding.btnTracking) {
 | 
			
		||||
                if (trackManager.hasLoggedServices()) {
 | 
			
		||||
                    isVisible = true
 | 
			
		||||
 | 
			
		||||
                    if (trackCount > 0) {
 | 
			
		||||
                        setIconResource(R.drawable.ic_done_24dp)
 | 
			
		||||
                        text = view.context.resources.getQuantityString(
 | 
			
		||||
                            R.plurals.num_trackers,
 | 
			
		||||
                            trackCount,
 | 
			
		||||
                            trackCount,
 | 
			
		||||
                        )
 | 
			
		||||
                        isActivated = true
 | 
			
		||||
                    } else {
 | 
			
		||||
                        setIconResource(R.drawable.ic_sync_24dp)
 | 
			
		||||
                        text = view.context.getString(R.string.manga_tracking_tab)
 | 
			
		||||
                        isActivated = false
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    clicks()
 | 
			
		||||
                        .onEach { controller.onTrackingClick() }
 | 
			
		||||
                        .launchIn(controller.viewScope)
 | 
			
		||||
                } else {
 | 
			
		||||
                    isVisible = false
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (controller.presenter.source is HttpSource) {
 | 
			
		||||
                binding.btnWebview.isVisible = true
 | 
			
		||||
                binding.btnWebview.clicks()
 | 
			
		||||
                    .onEach { controller.openMangaInWebView() }
 | 
			
		||||
                    .launchIn(controller.viewScope)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            binding.mangaFullTitle.longClicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.activity?.copyToClipboard(
 | 
			
		||||
                        view.context.getString(R.string.title),
 | 
			
		||||
                        binding.mangaFullTitle.text.toString(),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaFullTitle.clicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaAuthor.longClicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.activity?.copyToClipboard(
 | 
			
		||||
                        binding.mangaAuthor.text.toString(),
 | 
			
		||||
                        binding.mangaAuthor.text.toString(),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaAuthor.clicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.performGlobalSearch(binding.mangaAuthor.text.toString())
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaArtist.longClicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.activity?.copyToClipboard(
 | 
			
		||||
                        binding.mangaArtist.text.toString(),
 | 
			
		||||
                        binding.mangaArtist.text.toString(),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaArtist.clicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.performGlobalSearch(binding.mangaArtist.text.toString())
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            binding.mangaCover.clicks()
 | 
			
		||||
                .onEach {
 | 
			
		||||
                    controller.showFullCoverDialog()
 | 
			
		||||
                }
 | 
			
		||||
                .launchIn(controller.viewScope)
 | 
			
		||||
 | 
			
		||||
            setMangaInfo()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Update the view with manga information.
 | 
			
		||||
         *
 | 
			
		||||
         * @param manga manga object containing information about manga.
 | 
			
		||||
         * @param source the source of the manga.
 | 
			
		||||
         */
 | 
			
		||||
        private fun setMangaInfo() {
 | 
			
		||||
            // Update full title TextView.
 | 
			
		||||
            binding.mangaFullTitle.text = manga.title.ifBlank { view.context.getString(R.string.unknown) }
 | 
			
		||||
 | 
			
		||||
            // Update author TextView.
 | 
			
		||||
            binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
 | 
			
		||||
                view.context.getString(R.string.unknown_author)
 | 
			
		||||
            } else {
 | 
			
		||||
                manga.author
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update artist TextView.
 | 
			
		||||
            val hasArtist = !manga.artist.isNullOrBlank() && manga.artist != manga.author
 | 
			
		||||
            binding.mangaArtist.isVisible = hasArtist
 | 
			
		||||
            if (hasArtist) {
 | 
			
		||||
                binding.mangaArtist.text = manga.artist
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // If manga source is known update source TextView.
 | 
			
		||||
            binding.mangaMissingSourceIcon.isVisible = source is SourceManager.StubSource
 | 
			
		||||
 | 
			
		||||
            with(binding.mangaSource) {
 | 
			
		||||
                text = source.getNameForMangaInfo()
 | 
			
		||||
 | 
			
		||||
                setOnClickListener {
 | 
			
		||||
                    controller.performSearch(sourceManager.getOrStub(source.id).name)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update manga status.
 | 
			
		||||
            val (statusDrawable, statusString) = when (manga.status) {
 | 
			
		||||
                SManga.ONGOING -> R.drawable.ic_status_ongoing_24dp to R.string.ongoing
 | 
			
		||||
                SManga.COMPLETED -> R.drawable.ic_status_completed_24dp to R.string.completed
 | 
			
		||||
                SManga.LICENSED -> R.drawable.ic_status_licensed_24dp to R.string.licensed
 | 
			
		||||
                SManga.PUBLISHING_FINISHED -> R.drawable.ic_done_24dp to R.string.publishing_finished
 | 
			
		||||
                SManga.CANCELLED -> R.drawable.ic_close_24dp to R.string.cancelled
 | 
			
		||||
                SManga.ON_HIATUS -> R.drawable.ic_pause_24dp to R.string.on_hiatus
 | 
			
		||||
                else -> R.drawable.ic_status_unknown_24dp to R.string.unknown
 | 
			
		||||
            }
 | 
			
		||||
            binding.mangaStatusIcon.setImageResource(statusDrawable)
 | 
			
		||||
            binding.mangaStatus.setText(statusString)
 | 
			
		||||
 | 
			
		||||
            // Set the favorite drawable to the correct one.
 | 
			
		||||
            setFavoriteButtonState(manga.favorite)
 | 
			
		||||
 | 
			
		||||
            // Set cover if changed.
 | 
			
		||||
            binding.backdrop.loadAutoPause(manga)
 | 
			
		||||
            binding.mangaCover.loadAutoPause(manga)
 | 
			
		||||
 | 
			
		||||
            // Manga info section
 | 
			
		||||
            binding.mangaSummarySection.setTags(manga.getGenres(), controller::performGenreSearch)
 | 
			
		||||
            binding.mangaSummarySection.description = manga.description
 | 
			
		||||
            binding.mangaSummarySection.isVisible = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Update favorite button with correct drawable and text.
 | 
			
		||||
         *
 | 
			
		||||
         * @param isFavorite determines if manga is favorite or not.
 | 
			
		||||
         */
 | 
			
		||||
        private fun setFavoriteButtonState(isFavorite: Boolean) {
 | 
			
		||||
            // Set the Favorite drawable to the correct one.
 | 
			
		||||
            // Border drawable if false, filled drawable if true.
 | 
			
		||||
            val (iconResource, stringResource) = when (isFavorite) {
 | 
			
		||||
                true -> R.drawable.ic_favorite_24dp to R.string.in_library
 | 
			
		||||
                false -> R.drawable.ic_favorite_border_24dp to R.string.add_to_library
 | 
			
		||||
            }
 | 
			
		||||
            binding.btnFavorite.apply {
 | 
			
		||||
                setIconResource(iconResource)
 | 
			
		||||
                text = context.getString(stringResource)
 | 
			
		||||
                isActivated = isFavorite
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -80,7 +80,7 @@ class TrackSearchDialog : DialogController {
 | 
			
		||||
 | 
			
		||||
        // Do an initial search based on the manga's title
 | 
			
		||||
        if (savedViewState == null) {
 | 
			
		||||
            currentlySearched = trackController.presenter.manga.title
 | 
			
		||||
            currentlySearched = trackController.presenter.manga!!.title
 | 
			
		||||
            binding!!.titleInput.editText?.append(currentlySearched)
 | 
			
		||||
        }
 | 
			
		||||
        search(currentlySearched)
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import com.google.android.material.datepicker.CalendarConstraints
 | 
			
		||||
import com.google.android.material.datepicker.DateValidatorPointBackward
 | 
			
		||||
import com.google.android.material.datepicker.DateValidatorPointForward
 | 
			
		||||
import com.google.android.material.datepicker.MaterialDatePicker
 | 
			
		||||
import eu.kanade.domain.manga.model.toDbManga
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
 | 
			
		||||
@@ -25,7 +26,7 @@ import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
 | 
			
		||||
 | 
			
		||||
class TrackSheet(
 | 
			
		||||
    val controller: MangaController,
 | 
			
		||||
    val fragmentManager: FragmentManager,
 | 
			
		||||
    private val fragmentManager: FragmentManager,
 | 
			
		||||
) : BaseBottomSheetDialog(controller.activity!!),
 | 
			
		||||
    TrackAdapter.OnClickListener,
 | 
			
		||||
    SetTrackStatusDialog.Listener,
 | 
			
		||||
@@ -74,8 +75,8 @@ class TrackSheet(
 | 
			
		||||
 | 
			
		||||
    override fun onSetClick(position: Int) {
 | 
			
		||||
        val item = adapter.getItem(position) ?: return
 | 
			
		||||
        val manga = controller.presenter.manga
 | 
			
		||||
        val source = controller.presenter.source
 | 
			
		||||
        val manga = controller.presenter.manga?.toDbManga() ?: return
 | 
			
		||||
        val source = controller.presenter.source ?: return
 | 
			
		||||
 | 
			
		||||
        if (item.service is EnhancedTrackService) {
 | 
			
		||||
            if (item.track != null) {
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ class HistoryController : ComposeController<HistoryPresenter>(), RootController
 | 
			
		||||
            nestedScrollInterop = nestedScrollInterop,
 | 
			
		||||
            presenter = presenter,
 | 
			
		||||
            onClickCover = { history ->
 | 
			
		||||
                router.pushController(MangaController(history))
 | 
			
		||||
                router.pushController(MangaController(history.id))
 | 
			
		||||
            },
 | 
			
		||||
            onClickResume = { history ->
 | 
			
		||||
                presenter.getNextChapterForManga(history.mangaId, history.chapterId)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import eu.kanade.domain.manga.model.toDbManga
 | 
			
		||||
import eu.kanade.tachiyomi.data.cache.CoverCache
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.toDomainManga
 | 
			
		||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 | 
			
		||||
import eu.kanade.tachiyomi.source.LocalSource
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SManga
 | 
			
		||||
@@ -48,19 +49,18 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
 | 
			
		||||
    return coverCache.getCustomCoverFile(id).exists()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun Manga.removeCovers(coverCache: CoverCache) {
 | 
			
		||||
    if (isLocal()) return
 | 
			
		||||
fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
 | 
			
		||||
    if (isLocal()) return 0
 | 
			
		||||
 | 
			
		||||
    cover_last_modified = Date().time
 | 
			
		||||
    coverCache.deleteFromCache(this, true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun Manga.updateCoverLastModified(db: DatabaseHelper) {
 | 
			
		||||
    cover_last_modified = Date().time
 | 
			
		||||
    db.updateMangaCoverLastModified(this).executeAsBlocking()
 | 
			
		||||
    return coverCache.deleteFromCache(this, true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
 | 
			
		||||
    return toDomainManga()?.shouldDownloadNewChapters(db, prefs) ?: false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun DomainManga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
 | 
			
		||||
    if (!favorite) return false
 | 
			
		||||
 | 
			
		||||
    // Boolean to determine if user wants to automatically download new chapters.
 | 
			
		||||
@@ -75,7 +75,7 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
 | 
			
		||||
 | 
			
		||||
    // Get all categories, else default category (0)
 | 
			
		||||
    val categoriesForManga =
 | 
			
		||||
        db.getCategoriesForManga(this).executeAsBlocking()
 | 
			
		||||
        db.getCategoriesForManga(toDbManga()).executeAsBlocking()
 | 
			
		||||
            .mapNotNull { it.id }
 | 
			
		||||
            .takeUnless { it.isEmpty() } ?: listOf(0)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
package eu.kanade.tachiyomi.util.chapter
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 | 
			
		||||
@@ -34,6 +35,18 @@ object ChapterSettingsHelper {
 | 
			
		||||
        db.updateChapterFlags(manga).executeAsBlocking()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun applySettingDefaults(mangaId: Long, setMangaChapterFlags: SetMangaChapterFlags) {
 | 
			
		||||
        setMangaChapterFlags.awaitSetAllFlags(
 | 
			
		||||
            mangaId = mangaId,
 | 
			
		||||
            unreadFilter = prefs.filterChapterByRead().toLong(),
 | 
			
		||||
            downloadedFilter = prefs.filterChapterByDownloaded().toLong(),
 | 
			
		||||
            bookmarkedFilter = prefs.filterChapterByBookmarked().toLong(),
 | 
			
		||||
            sortingMode = prefs.sortChapterBySourceOrNumber().toLong(),
 | 
			
		||||
            sortingDirection = prefs.sortChapterByAscendingOrDescending().toLong(),
 | 
			
		||||
            displayMode = prefs.displayChapterByNameOrNumber().toLong(),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Updates all mangas in library with global Chapter Settings.
 | 
			
		||||
     */
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.util.chapter
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Chapter
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Manga
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
 | 
			
		||||
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga as DomainManga
 | 
			
		||||
 | 
			
		||||
fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
 | 
			
		||||
    return when (manga.sorting) {
 | 
			
		||||
@@ -20,3 +23,28 @@ fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending(
 | 
			
		||||
        else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun getChapterSort(
 | 
			
		||||
    manga: DomainManga,
 | 
			
		||||
    sortDescending: Boolean = manga.sortDescending(),
 | 
			
		||||
): (DomainChapter, DomainChapter) -> Int {
 | 
			
		||||
    return when (manga.sorting) {
 | 
			
		||||
        DomainManga.CHAPTER_SORTING_SOURCE -> when (sortDescending) {
 | 
			
		||||
            true -> { c1, c2 -> c1.sourceOrder.compareTo(c2.sourceOrder) }
 | 
			
		||||
            false -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
 | 
			
		||||
        }
 | 
			
		||||
        DomainManga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
 | 
			
		||||
            true -> { c1, c2 ->
 | 
			
		||||
                c2.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c1.chapterNumber.toString())
 | 
			
		||||
            }
 | 
			
		||||
            false -> { c1, c2 ->
 | 
			
		||||
                c1.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c2.chapterNumber.toString())
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        DomainManga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
 | 
			
		||||
            true -> { c1, c2 -> c2.dateUpload.compareTo(c1.dateUpload) }
 | 
			
		||||
            false -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
 | 
			
		||||
        }
 | 
			
		||||
        else -> throw NotImplementedError("Unimplemented sorting method")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,196 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.widget
 | 
			
		||||
 | 
			
		||||
import android.animation.AnimatorSet
 | 
			
		||||
import android.animation.ValueAnimator
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.graphics.drawable.Animatable
 | 
			
		||||
import android.util.AttributeSet
 | 
			
		||||
import android.view.LayoutInflater
 | 
			
		||||
import android.widget.FrameLayout
 | 
			
		||||
import androidx.annotation.AttrRes
 | 
			
		||||
import androidx.annotation.StyleRes
 | 
			
		||||
import androidx.constraintlayout.widget.ConstraintLayout
 | 
			
		||||
import androidx.core.content.ContextCompat
 | 
			
		||||
import androidx.core.view.doOnNextLayout
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import androidx.core.view.updateLayoutParams
 | 
			
		||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.MangaSummaryBinding
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
 | 
			
		||||
import eu.kanade.tachiyomi.util.view.setChips
 | 
			
		||||
import kotlin.math.roundToInt
 | 
			
		||||
import kotlin.math.roundToLong
 | 
			
		||||
 | 
			
		||||
class MangaSummaryView @JvmOverloads constructor(
 | 
			
		||||
    context: Context,
 | 
			
		||||
    attrs: AttributeSet? = null,
 | 
			
		||||
    @AttrRes defStyleAttr: Int = 0,
 | 
			
		||||
    @StyleRes defStyleRes: Int = 0,
 | 
			
		||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
 | 
			
		||||
 | 
			
		||||
    private val binding = MangaSummaryBinding.inflate(LayoutInflater.from(context), this, true)
 | 
			
		||||
 | 
			
		||||
    private var animatorSet: AnimatorSet? = null
 | 
			
		||||
 | 
			
		||||
    private var recalculateHeights = false
 | 
			
		||||
    private var descExpandedHeight = -1
 | 
			
		||||
    private var descShrunkHeight = -1
 | 
			
		||||
 | 
			
		||||
    var expanded = false
 | 
			
		||||
        set(value) {
 | 
			
		||||
            if (field != value) {
 | 
			
		||||
                field = value
 | 
			
		||||
                updateExpandState()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    var description: CharSequence? = null
 | 
			
		||||
        set(value) {
 | 
			
		||||
            if (field != value) {
 | 
			
		||||
                field = if (value.isNullOrBlank()) {
 | 
			
		||||
                    context.getString(R.string.unknown)
 | 
			
		||||
                } else {
 | 
			
		||||
                    value
 | 
			
		||||
                }
 | 
			
		||||
                binding.descriptionText.text = field
 | 
			
		||||
                recalculateHeights = true
 | 
			
		||||
                doOnNextLayout {
 | 
			
		||||
                    updateExpandState()
 | 
			
		||||
                }
 | 
			
		||||
                if (!isInLayout) {
 | 
			
		||||
                    requestLayout()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    fun setTags(items: List<String>?, onClick: (item: String) -> Unit) {
 | 
			
		||||
        listOfNotNull(binding.tagChipsShrunk, binding.tagChipsExpanded).forEach { chips ->
 | 
			
		||||
            chips.setChips(items, onClick) { tag -> context.copyToClipboard(tag, tag) }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun updateExpandState() = binding.apply {
 | 
			
		||||
        val initialSetup = descriptionText.maxHeight < 0
 | 
			
		||||
 | 
			
		||||
        val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight
 | 
			
		||||
        val maxHeightStart = if (initialSetup) maxHeightTarget else descriptionText.maxHeight
 | 
			
		||||
        val descMaxHeightAnimator = ValueAnimator().apply {
 | 
			
		||||
            setIntValues(maxHeightStart, maxHeightTarget)
 | 
			
		||||
            addUpdateListener {
 | 
			
		||||
                descriptionText.maxHeight = it.animatedValue as Int
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val toggleDrawable = ContextCompat.getDrawable(
 | 
			
		||||
            context,
 | 
			
		||||
            if (expanded) R.drawable.anim_caret_up else R.drawable.anim_caret_down,
 | 
			
		||||
        )
 | 
			
		||||
        toggleMore.setImageDrawable(toggleDrawable)
 | 
			
		||||
 | 
			
		||||
        var pastHalf = false
 | 
			
		||||
        val toggleTarget = if (expanded) 1F else 0F
 | 
			
		||||
        val toggleStart = if (initialSetup) {
 | 
			
		||||
            toggleTarget
 | 
			
		||||
        } else {
 | 
			
		||||
            toggleMore.translationY / toggleMore.height
 | 
			
		||||
        }
 | 
			
		||||
        val toggleAnimator = ValueAnimator().apply {
 | 
			
		||||
            setFloatValues(toggleStart, toggleTarget)
 | 
			
		||||
            addUpdateListener {
 | 
			
		||||
                val value = it.animatedValue as Float
 | 
			
		||||
 | 
			
		||||
                toggleMore.translationY = toggleMore.height * value
 | 
			
		||||
                descriptionScrim.translationY = toggleMore.translationY
 | 
			
		||||
                toggleMoreScrim.translationY = toggleMore.translationY
 | 
			
		||||
                tagChipsShrunkContainer.updateLayoutParams<ConstraintLayout.LayoutParams> {
 | 
			
		||||
                    topMargin = toggleMore.translationY.roundToInt()
 | 
			
		||||
                }
 | 
			
		||||
                tagChipsExpanded.updateLayoutParams<ConstraintLayout.LayoutParams> {
 | 
			
		||||
                    topMargin = toggleMore.translationY.roundToInt()
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Update non-animatable objects mid-animation makes it feel less abrupt
 | 
			
		||||
                if (it.animatedFraction >= 0.5F && !pastHalf) {
 | 
			
		||||
                    pastHalf = true
 | 
			
		||||
                    descriptionText.text = trimWhenNeeded(description)
 | 
			
		||||
                    tagChipsShrunkContainer.scrollX = 0
 | 
			
		||||
                    tagChipsShrunkContainer.isVisible = !expanded
 | 
			
		||||
                    tagChipsExpanded.isVisible = expanded
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        animatorSet?.cancel()
 | 
			
		||||
        animatorSet = AnimatorSet().apply {
 | 
			
		||||
            interpolator = FastOutSlowInInterpolator()
 | 
			
		||||
            duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong()
 | 
			
		||||
            playTogether(toggleAnimator, descMaxHeightAnimator)
 | 
			
		||||
            start()
 | 
			
		||||
        }
 | 
			
		||||
        (toggleDrawable as? Animatable)?.start()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun trimWhenNeeded(text: CharSequence?): CharSequence? {
 | 
			
		||||
        return if (!expanded) {
 | 
			
		||||
            text
 | 
			
		||||
                ?.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
 | 
			
		||||
                ?.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
 | 
			
		||||
        } else {
 | 
			
		||||
            text
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
 | 
			
		||||
        // Wait until parent view has determined the exact width
 | 
			
		||||
        // because this affect the description line count
 | 
			
		||||
        val measureWidthFreely = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY
 | 
			
		||||
        if (!recalculateHeights || measureWidthFreely) {
 | 
			
		||||
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        recalculateHeights = false
 | 
			
		||||
 | 
			
		||||
        // Measure with expanded lines
 | 
			
		||||
        binding.descriptionText.maxLines = Int.MAX_VALUE
 | 
			
		||||
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
 | 
			
		||||
        descExpandedHeight = binding.descriptionText.measuredHeight
 | 
			
		||||
 | 
			
		||||
        // Measure with shrunk lines
 | 
			
		||||
        binding.descriptionText.maxLines = SHRUNK_DESC_MAX_LINES
 | 
			
		||||
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
 | 
			
		||||
        descShrunkHeight = binding.descriptionText.measuredHeight
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init {
 | 
			
		||||
        binding.descriptionText.apply {
 | 
			
		||||
            // So that 1 line of text won't be hidden by scrim
 | 
			
		||||
            minLines = DESC_MIN_LINES
 | 
			
		||||
 | 
			
		||||
            setOnLongClickListener {
 | 
			
		||||
                description?.let {
 | 
			
		||||
                    context.copyToClipboard(
 | 
			
		||||
                        context.getString(R.string.description),
 | 
			
		||||
                        it.toString(),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                true
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        arrayOf(
 | 
			
		||||
            binding.descriptionText,
 | 
			
		||||
            binding.descriptionScrim,
 | 
			
		||||
            binding.toggleMoreScrim,
 | 
			
		||||
            binding.toggleMore,
 | 
			
		||||
        ).forEach {
 | 
			
		||||
            it.setOnClickListener { expanded = !expanded }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private const val TOGGLE_ANIM_DURATION = 300L
 | 
			
		||||
 | 
			
		||||
private const val DESC_MIN_LINES = 2
 | 
			
		||||
private const val SHRUNK_DESC_MAX_LINES = 3
 | 
			
		||||
@@ -4,6 +4,7 @@ import android.view.LayoutInflater
 | 
			
		||||
import android.view.inputmethod.InputMethodManager
 | 
			
		||||
import android.widget.TextView
 | 
			
		||||
import androidx.annotation.StringRes
 | 
			
		||||
import androidx.appcompat.app.AlertDialog
 | 
			
		||||
import androidx.core.content.getSystemService
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import androidx.core.widget.doAfterTextChanged
 | 
			
		||||
@@ -11,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
 | 
			
		||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding
 | 
			
		||||
import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding
 | 
			
		||||
import kotlinx.coroutines.suspendCancellableCoroutine
 | 
			
		||||
import kotlin.coroutines.resume
 | 
			
		||||
 | 
			
		||||
fun MaterialAlertDialogBuilder.setTextInput(
 | 
			
		||||
    hint: String? = null,
 | 
			
		||||
@@ -71,3 +74,19 @@ fun MaterialAlertDialogBuilder.setQuadStateMultiChoiceItems(
 | 
			
		||||
    }
 | 
			
		||||
    return setView(binding.root)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
suspend fun MaterialAlertDialogBuilder.await(
 | 
			
		||||
    @StringRes positiveLabelId: Int,
 | 
			
		||||
    @StringRes negativeLabelId: Int,
 | 
			
		||||
    @StringRes neutralLabelId: Int? = null,
 | 
			
		||||
) = suspendCancellableCoroutine<Int> { cont ->
 | 
			
		||||
    setPositiveButton(positiveLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_POSITIVE) }
 | 
			
		||||
    setNegativeButton(negativeLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEGATIVE) }
 | 
			
		||||
    if (neutralLabelId != null) {
 | 
			
		||||
        setNeutralButton(neutralLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEUTRAL) }
 | 
			
		||||
    }
 | 
			
		||||
    setOnDismissListener { cont.cancel() }
 | 
			
		||||
 | 
			
		||||
    val dialog = show()
 | 
			
		||||
    cont.invokeOnCancellation { dialog.dismiss() }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user