Prechádzať zdrojové kódy

MangaController overhaul (#7244)

Ivan Iskandar 2 rokov pred
rodič
commit
33a778873a
57 zmenil súbory, kde vykonal 3617 pridanie a 2931 odobranie
  1. 6 1
      app/build.gradle.kts
  2. 4 0
      app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt
  3. 2 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  4. 4 0
      app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt
  5. 95 0
      app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt
  6. 13 1
      app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt
  7. 2 0
      app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt
  8. 101 0
      app/src/main/java/eu/kanade/presentation/components/Button.kt
  9. 111 0
      app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt
  10. 108 0
      app/src/main/java/eu/kanade/presentation/components/Surface.kt
  11. 803 0
      app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt
  12. 12 3
      app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt
  13. 61 0
      app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt
  14. 9 0
      app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt
  15. 197 0
      app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt
  16. 139 0
      app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt
  17. 616 0
      app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt
  18. 237 0
      app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt
  19. 141 0
      app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt
  20. 24 0
      app/src/main/java/eu/kanade/presentation/util/LazyListState.kt
  21. 158 0
      app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt
  22. 24 0
      app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt
  23. 19 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt
  24. 20 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt
  25. 2 0
      app/src/main/java/eu/kanade/tachiyomi/source/Source.kt
  26. 27 0
      app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt
  27. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt
  28. 0 40
      app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt
  29. 4 12
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  30. 223 865
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  31. 354 340
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt
  32. 0 127
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt
  33. 0 33
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt
  34. 0 46
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt
  35. 20 27
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt
  36. 0 30
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt
  37. 0 69
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt
  38. 0 276
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt
  39. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt
  40. 4 3
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt
  41. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt
  42. 8 8
      app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt
  43. 13 0
      app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt
  44. 28 0
      app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt
  45. 0 196
      app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt
  46. 19 0
      app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt
  47. 0 84
      app/src/main/res/drawable/anim_caret_up.xml
  48. 0 59
      app/src/main/res/layout-sw720dp/manga_controller.xml
  49. 0 208
      app/src/main/res/layout-sw720dp/manga_info_header.xml
  50. 0 38
      app/src/main/res/layout/manga_chapters_header.xml
  51. 0 36
      app/src/main/res/layout/manga_controller.xml
  52. 0 37
      app/src/main/res/layout/manga_full_cover_dialog.xml
  53. 0 220
      app/src/main/res/layout/manga_info_header.xml
  54. 0 94
      app/src/main/res/layout/manga_summary.xml
  55. 0 24
      app/src/main/res/menu/full_cover.xml
  56. 0 49
      app/src/main/res/menu/manga.xml
  57. 6 2
      gradle/compose.versions.toml

+ 6 - 1
app/build.gradle.kts

@@ -150,13 +150,16 @@ dependencies {
     implementation(compose.activity)
     implementation(compose.foundation)
     implementation(compose.material3.core)
+    implementation(compose.material3.windowsizeclass)
     implementation(compose.material3.adapter)
     implementation(compose.material.icons)
     implementation(compose.animation)
+    implementation(compose.animation.graphics)
     implementation(compose.ui.tooling)
     implementation(compose.ui.util)
     implementation(compose.accompanist.webview)
     implementation(compose.accompanist.swiperefresh)
+    implementation(compose.accompanist.flowlayout)
 
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)
@@ -299,7 +302,9 @@ tasks {
             "-opt-in=coil.annotation.ExperimentalCoilApi",
             "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
             "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
-            "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
+            "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
+            "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
+            "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
         )
     }
 

+ 4 - 0
app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt

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

+ 2 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

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

+ 4 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaWithChapters.kt

@@ -20,4 +20,8 @@ class GetMangaWithChapters(
             Pair(manga, chapters)
         }
     }
+
+    suspend fun awaitManga(id: Long): Manga {
+        return mangaRepository.getMangaById(id)
+    }
 }

+ 95 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/SetMangaChapterFlags.kt

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

+ 13 - 1
app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt

@@ -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),
+        )
+    }
 }

+ 2 - 0
app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt

@@ -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 - 0
app/src/main/java/eu/kanade/presentation/components/Button.kt

@@ -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,
+                )
+            }
+        }
+    }
+}

+ 111 - 0
app/src/main/java/eu/kanade/presentation/components/FloatingActionButton.kt

@@ -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 - 0
app/src/main/java/eu/kanade/presentation/components/Surface.kt

@@ -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 - 0
app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt

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

+ 12 - 3
app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt

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

+ 61 - 0
app/src/main/java/eu/kanade/presentation/manga/components/ChapterHeader.kt

@@ -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
+                    },
+                )
+            }
+        }
+    }
+}

+ 9 - 0
app/src/main/java/eu/kanade/presentation/manga/components/DotSeparatorText.kt

@@ -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 = " • ")
+}

+ 197 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt

@@ -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,
+            )
+        }
+    }
+}

+ 139 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt

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

+ 616 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt

@@ -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,
+            )
+        }
+    }
+}

+ 237 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt

@@ -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,
+            )
+        }
+    }
+}

+ 141 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaTopAppBar.kt

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

+ 24 - 0
app/src/main/java/eu/kanade/presentation/util/LazyListState.kt

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

+ 158 - 0
app/src/main/java/eu/kanade/presentation/util/TopAppBarScrollBehavior.kt

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

+ 24 - 0
app/src/main/java/eu/kanade/presentation/util/WindowSizeClass.kt

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

+ 19 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt

@@ -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,
+    )
+}

+ 20 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt

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

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/source/Source.kt

@@ -121,3 +121,5 @@ fun Source.getNameForMangaInfo(): String {
         else -> toString()
     }
 }
+
+fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource

+ 27 - 0
app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt

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

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt

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

+ 4 - 12
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

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

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 223 - 865
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 354 - 340
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt


+ 0 - 127
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt

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

+ 0 - 33
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt

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

+ 0 - 46
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt

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

+ 20 - 27
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt

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

+ 0 - 30
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt

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

+ 0 - 69
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaChaptersHeaderAdapter.kt

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

+ 0 - 276
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt

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

+ 4 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt

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

+ 8 - 8
app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt

@@ -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)
+    return coverCache.deleteFromCache(this, true)
 }
 
-fun Manga.updateCoverLastModified(db: DatabaseHelper) {
-    cover_last_modified = Date().time
-    db.updateMangaCoverLastModified(this).executeAsBlocking()
+fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
+    return toDomainManga()?.shouldDownloadNewChapters(db, prefs) ?: false
 }
 
-fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
+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)
 

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSettingsHelper.kt

@@ -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.
      */

+ 28 - 0
app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSorter.kt

@@ -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")
+    }
+}

+ 0 - 196
app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt

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

+ 19 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/materialdialogs/MaterialAlertDialogBuilderExtensions.kt

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

+ 0 - 84
app/src/main/res/drawable/anim_caret_up.xml

@@ -1,84 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:aapt="http://schemas.android.com/aapt">
-    <aapt:attr name="android:drawable">
-        <vector
-            android:name="caret_up"
-            android:height="24.0dip"
-            android:width="24.0dip"
-            android:viewportWidth="24.0"
-            android:viewportHeight="24.0">
-            <group
-                android:name="caret02"
-                android:rotation="90.0"
-                android:translateX="12.0"
-                android:translateY="9.0">
-                <group
-                    android:name="caret02_l"
-                    android:rotation="-45.0">
-                    <group
-                        android:name="caret02_l_pivot"
-                        android:translateY="4.0">
-                        <group
-                            android:name="caret02_l_rect_position"
-                            android:translateY="-1.0">
-                            <path
-                                android:name="caret02_l_rect"
-                                android:fillColor="@android:color/black"
-                                android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
-                        </group>
-                    </group>
-                </group>
-                <group
-                    android:name="caret02_r"
-                    android:rotation="45.0">
-                    <group
-                        android:name="caret02_r_pivot"
-                        android:translateY="-4.0">
-                        <group
-                            android:name="caret02_r_rect_position"
-                            android:translateY="1.0">
-                            <path
-                                android:name="caret02_r_rect"
-                                android:fillColor="@android:color/black"
-                                android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
-                        </group>
-                    </group>
-                </group>
-            </group>
-        </vector>
-    </aapt:attr>
-
-    <target android:name="caret02">
-        <aapt:attr name="android:animation">
-            <objectAnimator
-                android:interpolator="@android:interpolator/fast_out_slow_in"
-                android:duration="300"
-                android:pathData="M 12.0,15.0 c 0.0,-1.0 0.0,-5.33333 0.0,-6.0"
-                android:propertyXName="translateX"
-                android:propertyYName="translateY" />
-        </aapt:attr>
-    </target>
-    <target android:name="caret02_l">
-        <aapt:attr name="android:animation">
-            <objectAnimator
-                android:interpolator="@android:interpolator/fast_out_slow_in"
-                android:duration="300"
-                android:valueFrom="45.0"
-                android:valueTo="-45.0"
-                android:valueType="floatType"
-                android:propertyName="rotation" />
-        </aapt:attr>
-    </target>
-    <target android:name="caret02_r">
-        <aapt:attr name="android:animation">
-            <objectAnimator
-                android:interpolator="@android:interpolator/fast_out_slow_in"
-                android:duration="300"
-                android:valueFrom="-45.0"
-                android:valueTo="45.0"
-                android:valueType="floatType"
-                android:propertyName="rotation" />
-        </aapt:attr>
-    </target>
-</animated-vector>

+ 0 - 59
app/src/main/res/layout-sw720dp/manga_controller.xml

@@ -1,59 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
-        android:id="@+id/swipe_refresh"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/linear_recycler_layout"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:orientation="horizontal">
-
-            <androidx.recyclerview.widget.RecyclerView
-                android:id="@+id/info_recycler"
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:clipToPadding="false"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toStartOf="@id/chapters_recycler"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent"
-                app:layout_constraintWidth_max="@dimen/tablet_sidebar_max_width"
-                app:layout_constraintWidth_percent="0.5"
-                tools:itemCount="1"
-                tools:listitem="@layout/manga_info_header" />
-
-            <androidx.recyclerview.widget.RecyclerView
-                android:id="@+id/chapters_recycler"
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:clipToPadding="false"
-                android:paddingBottom="@dimen/fab_list_padding"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toEndOf="@id/info_recycler"
-                app:layout_constraintTop_toTopOf="parent"
-                tools:listitem="@layout/chapters_item" />
-
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-    </eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_centerHorizontal="true"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 0 - 208
app/src/main/res/layout-sw720dp/manga_info_header.xml

@@ -1,208 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical"
-    tools:context=".ui.browse.source.browse.BrowseSourceController">
-
-    <ImageView
-        android:id="@+id/backdrop"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_marginBottom="-32dp"
-        android:alpha="0.2"
-        android:scaleType="centerCrop"
-        app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:background="@mipmap/ic_launcher"
-        tools:ignore="ContentDescription" />
-
-    <View
-        android:id="@+id/backdrop_overlay"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:alpha="1"
-        android:background="@drawable/manga_backdrop_gradient"
-        android:backgroundTint="?android:attr/colorBackground"
-        app:layout_constraintBottom_toBottomOf="@+id/backdrop"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <ImageView
-        android:id="@+id/manga_cover"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_marginStart="@dimen/tablet_horizontal_cover_margin"
-        android:layout_marginTop="32dp"
-        android:layout_marginEnd="@dimen/tablet_horizontal_cover_margin"
-        android:background="@drawable/rounded_rectangle"
-        android:contentDescription="@string/description_cover"
-        android:scaleType="centerCrop"
-        app:layout_constraintDimensionRatio="w,3:2"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:src="@mipmap/ic_launcher" />
-
-    <LinearLayout
-        android:id="@+id/manga_detail"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginTop="-8dp"
-        android:layout_marginEnd="16dp"
-        android:gravity="center_horizontal"
-        android:orientation="vertical"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/backdrop">
-
-        <TextView
-            android:id="@+id/manga_full_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginBottom="4dp"
-            android:gravity="center"
-            android:text="@string/manga_info_full_title_label"
-            android:textAppearance="?attr/textAppearanceTitleLarge"
-            android:textIsSelectable="false" />
-
-        <TextView
-            android:id="@+id/manga_author"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            android:textColor="?android:attr/textColorSecondary"
-            android:textAlignment="center"
-            android:textIsSelectable="false"
-            tools:text="Author" />
-
-        <TextView
-            android:id="@+id/manga_artist"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginBottom="4dp"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            android:textColor="?android:attr/textColorSecondary"
-            android:textIsSelectable="false"
-            tools:text="Artist" />
-
-        <LinearLayout
-            android:id="@+id/manga_status_row"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content">
-
-            <ImageView
-                android:id="@+id/manga_status_icon"
-                android:layout_width="16dp"
-                android:layout_height="match_parent"
-                android:layout_marginEnd="4dp"
-                app:srcCompat="@drawable/ic_status_unknown_24dp"
-                app:tint="?android:attr/textColorSecondary"
-                tools:ignore="ContentDescription" />
-
-            <TextView
-                android:id="@+id/manga_status"
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:text="Status" />
-
-            <ImageView
-                android:id="@+id/manga_missing_source_icon"
-                android:layout_width="16dp"
-                android:layout_height="match_parent"
-                android:layout_marginEnd="4dp"
-                app:srcCompat="@drawable/ic_warning_white_24dp"
-                app:tint="@color/error"
-                tools:ignore="ContentDescription" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginStart="4dp"
-                android:layout_marginEnd="4dp"
-                android:text="•"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/manga_source"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:text="Source" />
-
-        </LinearLayout>
-
-    </LinearLayout>
-
-    <LinearLayout
-        android:id="@+id/manga_actions"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginTop="8dp"
-        android:layout_marginEnd="16dp"
-        android:orientation="horizontal"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/manga_detail">
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_favorite"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/add_to_library"
-            app:icon="@drawable/ic_favorite_border_24dp" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_tracking"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/manga_tracking_tab"
-            android:visibility="gone"
-            app:icon="@drawable/ic_sync_24dp"
-            tools:visibility="visible" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_webview"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/action_web_view"
-            android:visibility="gone"
-            app:icon="@drawable/ic_public_24dp"
-            tools:visibility="visible" />
-
-    </LinearLayout>
-
-    <eu.kanade.tachiyomi.widget.MangaSummaryView
-        android:id="@+id/manga_summary_section"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/manga_actions" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 38
app/src/main/res/layout/manga_chapters_header.xml

@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:background="?attr/selectableItemBackground"
-    android:paddingStart="16dp"
-    android:paddingTop="4dp"
-    android:paddingEnd="12dp"
-    android:paddingBottom="4dp"
-    tools:context=".ui.browse.source.browse.BrowseSourceController">
-
-    <TextView
-        android:id="@+id/chapters_label"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:text="@string/chapters"
-        android:textAppearance="?attr/textAppearanceTitleMedium"
-        android:textIsSelectable="false"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/btn_chapters_filter"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <ImageButton
-        android:id="@+id/btn_chapters_filter"
-        android:layout_width="28dp"
-        android:layout_height="28dp"
-        android:background="?attr/selectableItemBackgroundBorderless"
-        android:contentDescription="@string/action_filter"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_filter_list_24dp"
-        app:tint="?attr/colorOnBackground" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 36
app/src/main/res/layout/manga_controller.xml

@@ -1,36 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
-        android:id="@+id/swipe_refresh"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:orientation="vertical">
-
-        <androidx.recyclerview.widget.RecyclerView
-            android:id="@+id/full_recycler"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:layout_marginStart="16dp"
-            android:layout_marginEnd="16dp"
-            android:clipToPadding="false"
-            android:paddingBottom="@dimen/fab_list_padding"
-            tools:listitem="@layout/chapters_item" />
-
-    </eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_centerHorizontal="true"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 0 - 37
app/src/main/res/layout/manga_full_cover_dialog.xml

@@ -1,37 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <com.google.android.material.appbar.AppBarLayout
-        android:id="@+id/appbar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <com.google.android.material.appbar.MaterialToolbar
-            android:id="@+id/toolbar"
-            android:layout_width="match_parent"
-            android:layout_height="?attr/actionBarSize"
-            android:theme="?attr/actionBarTheme"
-            app:contentInsetStartWithNavigation="0dp"
-            app:menu="@menu/full_cover"
-            app:navigationIcon="@drawable/ic_close_24dp" />
-
-    </com.google.android.material.appbar.AppBarLayout>
-
-    <eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
-        android:id="@+id/container"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:clipToPadding="false"
-        android:clipChildren="false"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/appbar" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 220
app/src/main/res/layout/manga_info_header.xml

@@ -1,220 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical"
-    tools:context=".ui.browse.source.browse.BrowseSourceController">
-
-    <ImageView
-        android:id="@+id/backdrop"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_marginBottom="-32dp"
-        android:alpha="0.2"
-        android:scaleType="centerCrop"
-        app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:background="@mipmap/ic_launcher"
-        tools:ignore="ContentDescription" />
-
-    <View
-        android:id="@+id/backdrop_overlay"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:background="@drawable/manga_backdrop_gradient"
-        android:backgroundTint="?android:attr/colorBackground"
-        app:layout_constraintBottom_toBottomOf="@+id/backdrop"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <androidx.constraintlayout.widget.Barrier
-        android:id="@+id/manga_cover_barrier"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:barrierDirection="top"
-        app:barrierMargin="16dp"
-        app:constraint_referenced_ids="manga_cover"
-        app:layout_constraintTop_toTopOf="@id/manga_cover" />
-
-    <ImageView
-        android:id="@+id/manga_cover"
-        android:layout_width="100dp"
-        android:layout_height="0dp"
-        android:layout_marginStart="16dp"
-        android:layout_marginTop="16dp"
-        android:background="@drawable/rounded_rectangle"
-        android:contentDescription="@string/description_cover"
-        android:maxWidth="100dp"
-        android:scaleType="centerCrop"
-        app:layout_constraintDimensionRatio="w,3:2"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:layout_height="133dp"
-        tools:src="@mipmap/ic_launcher" />
-
-    <LinearLayout
-        android:id="@+id/manga_detail"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginEnd="16dp"
-        android:gravity="center_vertical"
-        android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="@id/manga_info_barrier"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toEndOf="@+id/manga_cover"
-        app:layout_constraintTop_toTopOf="@id/manga_cover_barrier">
-
-        <TextView
-            android:id="@+id/manga_full_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:text="@string/manga_info_full_title_label"
-            android:textAppearance="?attr/textAppearanceTitleLarge"
-            android:textIsSelectable="false" />
-
-        <TextView
-            android:id="@+id/manga_author"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="4dp"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            android:textColor="?android:attr/textColorSecondary"
-            android:textIsSelectable="false"
-            tools:text="Author" />
-
-        <TextView
-            android:id="@+id/manga_artist"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="2dp"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            android:textColor="?android:attr/textColorSecondary"
-            android:textIsSelectable="false"
-            tools:text="Artist" />
-
-        <LinearLayout
-            android:id="@+id/manga_status_row"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="4dp">
-
-            <ImageView
-                android:id="@+id/manga_status_icon"
-                android:layout_width="16dp"
-                android:layout_height="match_parent"
-                android:layout_marginEnd="4dp"
-                app:srcCompat="@drawable/ic_status_unknown_24dp"
-                app:tint="?android:attr/textColorSecondary"
-                tools:ignore="ContentDescription" />
-
-            <TextView
-                android:id="@+id/manga_status"
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:text="Status" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginStart="4dp"
-                android:layout_marginEnd="4dp"
-                android:text="•"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:ignore="HardcodedText" />
-
-            <ImageView
-                android:id="@+id/manga_missing_source_icon"
-                android:layout_width="16dp"
-                android:layout_height="match_parent"
-                android:layout_marginEnd="4dp"
-                app:srcCompat="@drawable/ic_warning_white_24dp"
-                app:tint="@color/error"
-                tools:ignore="ContentDescription" />
-
-            <TextView
-                android:id="@+id/manga_source"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:textAppearance="?attr/textAppearanceBodyMedium"
-                android:textColor="?android:attr/textColorSecondary"
-                android:textIsSelectable="false"
-                tools:text="Source" />
-
-        </LinearLayout>
-
-    </LinearLayout>
-
-    <androidx.constraintlayout.widget.Barrier
-        android:id="@+id/manga_info_barrier"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:barrierDirection="bottom"
-        app:constraint_referenced_ids="manga_cover,manga_detail" />
-
-    <LinearLayout
-        android:id="@+id/manga_actions"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginTop="4dp"
-        android:layout_marginEnd="16dp"
-        android:orientation="horizontal"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/manga_info_barrier">
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_favorite"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/add_to_library"
-            app:icon="@drawable/ic_favorite_border_24dp" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_tracking"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/manga_tracking_tab"
-            android:visibility="gone"
-            app:icon="@drawable/ic_sync_24dp"
-            tools:visibility="visible" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btn_webview"
-            style="@style/Widget.Tachiyomi.Button.ActionButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/action_web_view"
-            android:visibility="gone"
-            app:icon="@drawable/ic_public_24dp"
-            tools:visibility="visible" />
-
-    </LinearLayout>
-
-    <eu.kanade.tachiyomi.widget.MangaSummaryView
-        android:id="@+id/manga_summary_section"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/manga_actions" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 94
app/src/main/res/layout/manga_summary.xml

@@ -1,94 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-
-    <TextView
-        android:id="@+id/description_text"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginHorizontal="16dp"
-        android:ellipsize="end"
-        android:textAppearance="?attr/textAppearanceBodyMedium"
-        android:textColor="?android:attr/textColorSecondary"
-        android:textIsSelectable="false"
-        app:firstBaselineToTopHeight="0dp"
-        app:lastBaselineToBottomHeight="0dp"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:text="Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content Collapsed summary content" />
-
-    <View
-        android:id="@+id/description_scrim"
-        android:layout_width="0dp"
-        android:layout_height="24sp"
-        android:background="@drawable/manga_info_gradient"
-        android:backgroundTint="?android:attr/colorBackground"
-        app:layout_constraintBottom_toBottomOf="@+id/description_text"
-        app:layout_constraintEnd_toEndOf="@+id/description_text"
-        app:layout_constraintStart_toStartOf="@+id/description_text" />
-
-    <View
-        android:id="@+id/toggle_more_scrim"
-        android:layout_width="36sp"
-        android:layout_height="18sp"
-        android:background="@drawable/manga_info_more_gradient"
-        android:backgroundTint="?android:attr/colorBackground"
-        app:layout_constraintBottom_toBottomOf="@+id/toggle_more"
-        app:layout_constraintEnd_toEndOf="@+id/toggle_more"
-        app:layout_constraintStart_toStartOf="@+id/toggle_more"
-        app:layout_constraintTop_toTopOf="@+id/toggle_more" />
-
-    <ImageButton
-        android:id="@+id/toggle_more"
-        style="@style/Widget.Tachiyomi.Button.InlineButton"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="-6dp"
-        android:background="@android:color/transparent"
-        android:contentDescription="@string/manga_info_expand"
-        android:padding="0dp"
-        android:src="@drawable/anim_caret_down"
-        app:layout_constraintBottom_toBottomOf="@id/description_text"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:tint="?android:attr/textColorPrimary" />
-
-    <HorizontalScrollView
-        android:id="@+id/tag_chips_shrunk_container"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:requiresFadingEdge="horizontal"
-        android:scrollbars="none"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/toggle_more">
-
-        <com.google.android.material.chip.ChipGroup
-            android:id="@+id/tag_chips_shrunk"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp"
-            android:paddingVertical="8dp"
-            app:chipSpacingHorizontal="4dp"
-            app:singleLine="true" />
-
-    </HorizontalScrollView>
-
-    <com.google.android.material.chip.ChipGroup
-        android:id="@+id/tag_chips_expanded"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingHorizontal="16dp"
-        android:paddingVertical="8dp"
-        android:visibility="gone"
-        app:chipSpacingHorizontal="4dp"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/toggle_more"
-        tools:visibility="visible" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 24
app/src/main/res/menu/full_cover.xml

@@ -1,24 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:android="http://schemas.android.com/apk/res/android">
-
-    <item
-        android:id="@+id/action_share_cover"
-        android:icon="@drawable/ic_share_24dp"
-        android:title="@string/action_share"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-    <item
-        android:id="@+id/action_save_cover"
-        android:icon="@drawable/ic_save_24dp"
-        android:title="@string/action_save"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-    <item
-        android:id="@+id/action_edit_cover"
-        android:icon="@drawable/ic_edit_24dp"
-        android:title="@string/action_edit"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-
-</menu>

+ 0 - 49
app/src/main/res/menu/manga.xml

@@ -1,49 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/action_share"
-        android:icon="@drawable/ic_share_24dp"
-        android:title="@string/action_share"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-
-    <item
-        android:id="@+id/download_group"
-        android:icon="@drawable/ic_get_app_24dp"
-        android:title="@string/manga_download"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom">
-        <menu>
-            <item
-                android:id="@+id/download_next"
-                android:title="@string/download_1" />
-            <item
-                android:id="@+id/download_next_5"
-                android:title="@string/download_5" />
-            <item
-                android:id="@+id/download_next_10"
-                android:title="@string/download_10" />
-            <item
-                android:id="@+id/download_custom"
-                android:title="@string/download_custom" />
-            <item
-                android:id="@+id/download_unread"
-                android:title="@string/download_unread" />
-            <item
-                android:id="@+id/download_all"
-                android:title="@string/download_all" />
-        </menu>
-    </item>
-
-    <item
-        android:id="@+id/action_edit_categories"
-        android:title="@string/action_edit_categories"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/action_migrate"
-        android:title="@string/action_migrate"
-        app:showAsAction="never" />
-</menu>

+ 6 - 2
gradle/compose.versions.toml

@@ -1,17 +1,21 @@
 [versions]
 compose = "1.2.0-rc02"
 accompanist = "0.24.12-rc"
+material3 = "1.0.0-alpha13"
 
 [libraries]
 activity = "androidx.activity:activity-compose:1.6.0-alpha05"
 foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
 animation = { module = "androidx.compose.animation:animation", version.ref = "compose" }
+animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref="compose" }
 ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
 ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" }
 
-material3-core = "androidx.compose.material3:material3:1.0.0-alpha13"
+material3-core = { module = "androidx.compose.material3:material3", version.ref = "material3" }
+material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3" }
 material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.11"
 material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
 
 accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
-accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
+accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
+accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov