浏览代码

Convert cover dialog view to compose (#7346)

Ivan Iskandar 2 年之前
父节点
当前提交
8fedd2d5f1

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

@@ -18,6 +18,10 @@ class MangaRepositoryImpl(
         return handler.awaitOne { mangasQueries.getMangaById(id, mangaMapper) }
     }
 
+    override suspend fun subscribeMangaById(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) }
     }

+ 5 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/GetMangaById.kt

@@ -3,6 +3,7 @@ package eu.kanade.domain.manga.interactor
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.repository.MangaRepository
 import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.coroutines.flow.Flow
 import logcat.LogPriority
 
 class GetMangaById(
@@ -17,4 +18,8 @@ class GetMangaById(
             null
         }
     }
+
+    suspend fun subscribe(id: Long): Flow<Manga> {
+        return mangaRepository.subscribeMangaById(id)
+    }
 }

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

@@ -8,6 +8,8 @@ interface MangaRepository {
 
     suspend fun getMangaById(id: Long): Manga
 
+    suspend fun subscribeMangaById(id: Long): Flow<Manga>
+
     fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>>
 
     suspend fun getDuplicateLibraryManga(title: String, sourceId: Long): Manga?

+ 296 - 0
app/src/main/java/eu/kanade/presentation/components/Scaffold.kt

@@ -0,0 +1,296 @@
+/*
+ * 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.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.SmallTopAppBar
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+
+/**
+ * <a href="https://material.io/design/layout/understanding-layout.html" class="external" target="_blank">Material Design layout</a>.
+ *
+ * Scaffold implements the basic material design visual layout structure.
+ *
+ * This component provides API to put together several material components to construct your
+ * screen, by ensuring proper layout strategy for them and collecting necessary data so these
+ * components will work together correctly.
+ *
+ * Simple example of a Scaffold with [SmallTopAppBar], [FloatingActionButton]:
+ *
+ * @sample androidx.compose.material3.samples.SimpleScaffoldWithTopBar
+ *
+ * To show a [Snackbar], use [SnackbarHostState.showSnackbar].
+ *
+ * @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
+ *
+ * Tachiyomi changes:
+ * * Remove height constraint for expanded app bar
+ * * Also take account of fab height when providing inner padding
+ *
+ * @param modifier the [Modifier] to be applied to this scaffold
+ * @param topBar top app bar of the screen, typically a [SmallTopAppBar]
+ * @param bottomBar bottom bar of the screen, typically a [NavigationBar]
+ * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
+ * [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
+ * @param floatingActionButton Main action button of the screen, typically a [FloatingActionButton]
+ * @param floatingActionButtonPosition position of the FAB on the screen. See [FabPosition].
+ * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
+ * to have no color.
+ * @param contentColor the preferred color for content inside this scaffold. Defaults to either the
+ * matching content color for [containerColor], or to the current [LocalContentColor] if
+ * [containerColor] is not a color from the theme.
+ * @param content content of the screen. The lambda receives a [PaddingValues] that should be
+ * applied to the content root via [Modifier.padding] to properly offset top and bottom bars. If
+ * using [Modifier.verticalScroll], apply this modifier to the child of the scroll, and not on
+ * the scroll itself.
+ */
+@ExperimentalMaterial3Api
+@Composable
+fun Scaffold(
+    modifier: Modifier = Modifier,
+    topBar: @Composable () -> Unit = {},
+    bottomBar: @Composable () -> Unit = {},
+    snackbarHost: @Composable () -> Unit = {},
+    floatingActionButton: @Composable () -> Unit = {},
+    floatingActionButtonPosition: FabPosition = FabPosition.End,
+    containerColor: Color = MaterialTheme.colorScheme.background,
+    contentColor: Color = contentColorFor(containerColor),
+    content: @Composable (PaddingValues) -> Unit,
+) {
+    Surface(modifier = modifier, color = containerColor, contentColor = contentColor) {
+        ScaffoldLayout(
+            fabPosition = floatingActionButtonPosition,
+            topBar = topBar,
+            bottomBar = bottomBar,
+            content = content,
+            snackbar = snackbarHost,
+            fab = floatingActionButton,
+        )
+    }
+}
+
+/**
+ * Layout for a [Scaffold]'s content.
+ *
+ * @param fabPosition [FabPosition] for the FAB (if present)
+ * @param topBar the content to place at the top of the [Scaffold], typically a [SmallTopAppBar]
+ * @param content the main 'body' of the [Scaffold]
+ * @param snackbar the [Snackbar] displayed on top of the [content]
+ * @param fab the [FloatingActionButton] displayed on top of the [content], below the [snackbar]
+ * and above the [bottomBar]
+ * @param bottomBar the content to place at the bottom of the [Scaffold], on top of the
+ * [content], typically a [NavigationBar].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ScaffoldLayout(
+    fabPosition: FabPosition,
+    topBar: @Composable () -> Unit,
+    content: @Composable (PaddingValues) -> Unit,
+    snackbar: @Composable () -> Unit,
+    fab: @Composable () -> Unit,
+    bottomBar: @Composable () -> Unit,
+
+) {
+    SubcomposeLayout { constraints ->
+        val layoutWidth = constraints.maxWidth
+        val layoutHeight = constraints.maxHeight
+
+        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+        /**
+         * Tachiyomi: Remove height constraint for expanded app bar
+         */
+        val topBarConstraints = looseConstraints.copy(maxHeight = Constraints.Infinity)
+
+        layout(layoutWidth, layoutHeight) {
+            val topBarPlaceables = subcompose(ScaffoldLayoutContent.TopBar, topBar).map {
+                it.measure(topBarConstraints)
+            }
+
+            val topBarHeight = topBarPlaceables.maxByOrNull { it.height }?.height ?: 0
+
+            val snackbarPlaceables = subcompose(ScaffoldLayoutContent.Snackbar, snackbar).map {
+                it.measure(looseConstraints)
+            }
+
+            val snackbarHeight = snackbarPlaceables.maxByOrNull { it.height }?.height ?: 0
+            val snackbarWidth = snackbarPlaceables.maxByOrNull { it.width }?.width ?: 0
+
+            val fabPlaceables =
+                subcompose(ScaffoldLayoutContent.Fab, fab).mapNotNull { measurable ->
+                    measurable.measure(looseConstraints).takeIf { it.height != 0 && it.width != 0 }
+                }
+
+            val fabHeight = fabPlaceables.maxByOrNull { it.height }?.height ?: 0
+
+            val fabPlacement = if (fabPlaceables.isNotEmpty()) {
+                val fabWidth = fabPlaceables.maxByOrNull { it.width }!!.width
+                // FAB distance from the left of the layout, taking into account LTR / RTL
+                val fabLeftOffset = if (fabPosition == FabPosition.End) {
+                    if (layoutDirection == LayoutDirection.Ltr) {
+                        layoutWidth - FabSpacing.roundToPx() - fabWidth
+                    } else {
+                        FabSpacing.roundToPx()
+                    }
+                } else {
+                    (layoutWidth - fabWidth) / 2
+                }
+
+                FabPlacement(
+                    left = fabLeftOffset,
+                    width = fabWidth,
+                    height = fabHeight,
+                )
+            } else {
+                null
+            }
+
+            val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
+                CompositionLocalProvider(
+                    LocalFabPlacement provides fabPlacement,
+                    content = bottomBar,
+                )
+            }.map { it.measure(looseConstraints) }
+
+            val bottomBarHeight = bottomBarPlaceables.maxByOrNull { it.height }?.height ?: 0
+            val fabOffsetFromBottom = fabPlacement?.let {
+                if (bottomBarHeight == 0) {
+                    it.height + FabSpacing.roundToPx()
+                } else {
+                    // Total height is the bottom bar height + the FAB height + the padding
+                    // between the FAB and bottom bar
+                    bottomBarHeight + it.height + FabSpacing.roundToPx()
+                }
+            }
+
+            val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
+                snackbarHeight + (fabOffsetFromBottom ?: bottomBarHeight)
+            } else {
+                0
+            }
+
+            /**
+             * Tachiyomi: Also take account of fab height when providing inner padding
+             */
+            val bodyContentPlaceables = subcompose(ScaffoldLayoutContent.MainContent) {
+                val innerPadding = PaddingValues(
+                    top = topBarHeight.toDp(),
+                    bottom = bottomBarHeight.toDp() + fabHeight.toDp(),
+                )
+                content(innerPadding)
+            }.map { it.measure(looseConstraints) }
+
+            // Placing to control drawing order to match default elevation of each placeable
+
+            bodyContentPlaceables.forEach {
+                it.place(0, 0)
+            }
+            topBarPlaceables.forEach {
+                it.place(0, 0)
+            }
+            snackbarPlaceables.forEach {
+                it.place(
+                    (layoutWidth - snackbarWidth) / 2,
+                    layoutHeight - snackbarOffsetFromBottom,
+                )
+            }
+            // The bottom bar is always at the bottom of the layout
+            bottomBarPlaceables.forEach {
+                it.place(0, layoutHeight - bottomBarHeight)
+            }
+            // Explicitly not using placeRelative here as `leftOffset` already accounts for RTL
+            fabPlacement?.let { placement ->
+                fabPlaceables.forEach {
+                    it.place(placement.left, layoutHeight - fabOffsetFromBottom!!)
+                }
+            }
+        }
+    }
+}
+
+/**
+ * The possible positions for a [FloatingActionButton] attached to a [Scaffold].
+ */
+@ExperimentalMaterial3Api
+@JvmInline
+value class FabPosition internal constructor(@Suppress("unused") private val value: Int) {
+    companion object {
+        /**
+         * Position FAB at the bottom of the screen in the center, above the [NavigationBar] (if it
+         * exists)
+         */
+        val Center = FabPosition(0)
+
+        /**
+         * Position FAB at the bottom of the screen at the end, above the [NavigationBar] (if it
+         * exists)
+         */
+        val End = FabPosition(1)
+    }
+
+    override fun toString(): String {
+        return when (this) {
+            Center -> "FabPosition.Center"
+            else -> "FabPosition.End"
+        }
+    }
+}
+
+/**
+ * Placement information for a [FloatingActionButton] inside a [Scaffold].
+ *
+ * @property left the FAB's offset from the left edge of the bottom bar, already adjusted for RTL
+ * support
+ * @property width the width of the FAB
+ * @property height the height of the FAB
+ */
+@Immutable
+internal class FabPlacement(
+    val left: Int,
+    val width: Int,
+    val height: Int,
+)
+
+/**
+ * CompositionLocal containing a [FabPlacement] that is used to calculate the FAB bottom offset.
+ */
+internal val LocalFabPlacement = staticCompositionLocalOf<FabPlacement?> { null }
+
+// FAB spacing above the bottom bar / bottom of the Scaffold
+private val FabSpacing = 16.dp
+
+private enum class ScaffoldLayoutContent { TopBar, MainContent, Snackbar, Fab, BottomBar }

+ 6 - 0
app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt

@@ -0,0 +1,6 @@
+package eu.kanade.presentation.manga
+
+enum class EditCoverAction {
+    EDIT,
+    DELETE,
+}

+ 163 - 0
app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt

@@ -0,0 +1,163 @@
+package eu.kanade.presentation.manga.components
+
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.os.Build
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Save
+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.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.view.updatePadding
+import coil.imageLoader
+import coil.request.ImageRequest
+import coil.size.Size
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.components.DropdownMenu
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.manga.EditCoverAction
+import eu.kanade.presentation.util.clickableNoIndication
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
+
+@Composable
+fun MangaCoverDialog(
+    coverDataProvider: () -> Manga,
+    isCustomCover: Boolean,
+    onShareClick: () -> Unit,
+    onSaveClick: () -> Unit,
+    onEditClick: ((EditCoverAction) -> Unit)?,
+    onDismissRequest: () -> Unit,
+) {
+    Scaffold(
+        bottomBar = {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
+                    .padding(horizontal = 4.dp, vertical = 4.dp)
+                    .navigationBarsPadding(),
+            ) {
+                IconButton(onClick = onDismissRequest) {
+                    Icon(
+                        imageVector = Icons.Default.Close,
+                        contentDescription = stringResource(id = R.string.action_close),
+                    )
+                }
+                Spacer(modifier = Modifier.weight(1f))
+                IconButton(onClick = onShareClick) {
+                    Icon(
+                        imageVector = Icons.Default.Share,
+                        contentDescription = stringResource(id = R.string.action_share),
+                    )
+                }
+                IconButton(onClick = onSaveClick) {
+                    Icon(
+                        imageVector = Icons.Default.Save,
+                        contentDescription = stringResource(id = R.string.action_save),
+                    )
+                }
+                if (onEditClick != null) {
+                    Box {
+                        val (expanded, onExpand) = remember { mutableStateOf(false) }
+                        IconButton(
+                            onClick = { if (isCustomCover) onExpand(true) else onEditClick(EditCoverAction.EDIT) },
+                        ) {
+                            Icon(
+                                imageVector = Icons.Default.Edit,
+                                contentDescription = stringResource(id = R.string.action_edit_cover),
+                            )
+                        }
+                        DropdownMenu(
+                            expanded = expanded,
+                            onDismissRequest = { onExpand(false) },
+                        ) {
+                            DropdownMenuItem(
+                                text = { Text(text = stringResource(id = R.string.action_edit)) },
+                                onClick = {
+                                    onEditClick(EditCoverAction.EDIT)
+                                    onExpand(false)
+                                },
+                            )
+                            DropdownMenuItem(
+                                text = { Text(text = stringResource(id = R.string.action_delete)) },
+                                onClick = {
+                                    onEditClick(EditCoverAction.DELETE)
+                                    onExpand(false)
+                                },
+                            )
+                        }
+                    }
+                }
+            }
+        },
+    ) { contentPadding ->
+        val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current)
+        val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(color = MaterialTheme.colorScheme.background)
+                .clickableNoIndication(onClick = onDismissRequest),
+        ) {
+            AndroidView(
+                factory = {
+                    ReaderPageImageView(it).apply {
+                        onViewClicked = onDismissRequest
+                        clipToPadding = false
+                        clipChildren = false
+                    }
+                },
+                update = { view ->
+                    val request = ImageRequest.Builder(view.context)
+                        .data(coverDataProvider())
+                        .size(Size.ORIGINAL)
+                        .target { drawable ->
+                            // Copy bitmap in case it came from memory cache
+                            // Because SSIV needs to thoroughly read the image
+                            val copy = (drawable as? BitmapDrawable)?.let {
+                                val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                                    Bitmap.Config.HARDWARE
+                                } else {
+                                    Bitmap.Config.ARGB_8888
+                                }
+                                BitmapDrawable(
+                                    view.context.resources,
+                                    it.bitmap.copy(config, false),
+                                )
+                            } ?: drawable
+                            view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500))
+                        }
+                        .build()
+                    view.context.imageLoader.enqueue(request)
+
+                    view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx)
+                },
+                modifier = Modifier.fillMaxSize(),
+            )
+        }
+    }
+}

+ 76 - 0
app/src/main/java/eu/kanade/presentation/util/Modifier.kt

@@ -0,0 +1,76 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.DpSize
+import kotlin.math.roundToInt
+
+fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(.78f)
+
+fun Modifier.clickableNoIndication(
+    onLongClick: (() -> Unit)? = null,
+    onClick: () -> Unit,
+): Modifier = composed {
+    this.combinedClickable(
+        interactionSource = remember { MutableInteractionSource() },
+        indication = null,
+        onLongClick = onLongClick,
+        onClick = onClick,
+    )
+}
+
+@Suppress("ModifierInspectorInfo")
+fun Modifier.minimumTouchTargetSize(): Modifier = composed(
+    inspectorInfo = debugInspectorInfo {
+        name = "minimumTouchTargetSize"
+        properties["README"] = "Adds outer padding to measure at least 48.dp (default) in " +
+            "size to disambiguate touch interactions if the element would measure smaller"
+    },
+) {
+    if (LocalMinimumTouchTargetEnforcement.current) {
+        val size = LocalViewConfiguration.current.minimumTouchTargetSize
+        MinimumTouchTargetModifier(size)
+    } else {
+        Modifier
+    }
+}
+
+private class MinimumTouchTargetModifier(val size: DpSize) : LayoutModifier {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints,
+    ): MeasureResult {
+        val placeable = measurable.measure(constraints)
+
+        // Be at least as big as the minimum dimension in both dimensions
+        val width = maxOf(placeable.width, size.width.roundToPx())
+        val height = maxOf(placeable.height, size.height.roundToPx())
+
+        return layout(width, height) {
+            val centerX = ((width - placeable.width) / 2f).roundToInt()
+            val centerY = ((height - placeable.height) / 2f).roundToInt()
+            placeable.place(centerX, centerY)
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        val otherModifier = other as? MinimumTouchTargetModifier ?: return false
+        return size == otherModifier.size
+    }
+
+    override fun hashCode(): Int {
+        return size.hashCode()
+    }
+}

+ 28 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt

@@ -16,6 +16,30 @@ import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import nucleus.presenter.Presenter
 
+abstract class FullComposeController<P : Presenter<*>>(bundle: Bundle? = null) :
+    NucleusController<ComposeControllerBinding, P>(bundle),
+    FullComposeContentController {
+
+    override fun createBinding(inflater: LayoutInflater) =
+        ComposeControllerBinding.inflate(inflater)
+
+    override fun onViewCreated(view: View) {
+        super.onViewCreated(view)
+
+        binding.root.apply {
+            consumeWindowInsets = false
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                TachiyomiTheme {
+                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
+                        ComposeContent()
+                    }
+                }
+            }
+        }
+    }
+}
+
 /**
  * Compose controller with a Nucleus presenter.
  */
@@ -97,6 +121,10 @@ abstract class SearchableComposeController<P : BasePresenter<*>>(bundle: Bundle?
     }
 }
 
+interface FullComposeContentController {
+    @Composable fun ComposeContent()
+}
+
 interface ComposeContentController {
     @Composable fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
 }

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

@@ -43,6 +43,7 @@ import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
 import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
 import eu.kanade.tachiyomi.ui.base.controller.DialogController
 import eu.kanade.tachiyomi.ui.base.controller.FabController
+import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
 import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.TabbedController
@@ -599,6 +600,7 @@ class MainActivity : BaseActivity() {
             binding.fabLayout.rootFab.hide()
         }
 
+        val isFullComposeController = internalTo is FullComposeController<*>
         if (!isTablet()) {
             // Save lift state
             if (isPush) {
@@ -622,8 +624,16 @@ class MainActivity : BaseActivity() {
 
             binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController
 
-            binding.appbar.isTransparentWhenNotLifted = internalTo is MangaController
-            binding.controllerContainer.overlapHeader = internalTo is MangaController
+            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
         }
     }
 

+ 3 - 134
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -1,11 +1,7 @@
 package eu.kanade.tachiyomi.ui.manga
 
-import android.app.Activity
 import android.app.ActivityOptions
-import android.content.Context
 import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.drawable.BitmapDrawable
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.Menu
@@ -24,8 +20,6 @@ import androidx.recyclerview.widget.ConcatAdapter
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import coil.imageLoader
-import coil.request.ImageRequest
 import com.bluelinelabs.conductor.ControllerChangeHandler
 import com.bluelinelabs.conductor.ControllerChangeType
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
@@ -45,8 +39,6 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.DownloadService
 import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.data.saver.Image
-import eu.kanade.tachiyomi.data.saver.Location
 import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 import eu.kanade.tachiyomi.data.track.TrackService
 import eu.kanade.tachiyomi.data.track.model.TrackSearch
@@ -61,12 +53,12 @@ import eu.kanade.tachiyomi.ui.base.controller.FabController
 import eu.kanade.tachiyomi.ui.base.controller.NucleusController
 import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
 import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
 import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
-import eu.kanade.tachiyomi.ui.library.ChangeMangaCoverDialog
 import eu.kanade.tachiyomi.ui.library.LibraryController
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
@@ -85,12 +77,9 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
 import eu.kanade.tachiyomi.ui.recent.history.HistoryController
 import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
 import eu.kanade.tachiyomi.ui.webview.WebViewActivity
-import eu.kanade.tachiyomi.util.hasCustomCover
 import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.lang.launchUI
 import eu.kanade.tachiyomi.util.lang.withUIContext
 import eu.kanade.tachiyomi.util.system.logcat
-import eu.kanade.tachiyomi.util.system.toShareIntent
 import eu.kanade.tachiyomi.util.system.toast
 import eu.kanade.tachiyomi.util.view.shrinkOnScroll
 import eu.kanade.tachiyomi.util.view.snack
@@ -115,7 +104,6 @@ class MangaController :
     FlexibleAdapter.OnItemClickListener,
     FlexibleAdapter.OnItemLongClickListener,
     BaseChaptersAdapter.OnChapterClickListener,
-    ChangeMangaCoverDialog.Listener,
     ChangeMangaCategoriesDialog.Listener,
     DownloadCustomChaptersDialog.Listener,
     DeleteChaptersDialog.Listener {
@@ -724,128 +712,9 @@ class MangaController :
         }
     }
 
-    /**
-     * Fetches the cover with Coil, turns it into Bitmap and does something with it (asynchronous)
-     * @param context The context for building and executing the ImageRequest
-     * @param coverHandler A function that describes what should be done with the Bitmap
-     */
-    private fun useCoverAsBitmap(context: Context, coverHandler: (Bitmap) -> Unit) {
-        val req = ImageRequest.Builder(context)
-            .data(manga)
-            .target { result ->
-                val coverBitmap = (result as BitmapDrawable).bitmap
-                coverHandler(coverBitmap)
-            }
-            .build()
-        context.imageLoader.enqueue(req)
-    }
-
     fun showFullCoverDialog() {
-        val manga = manga ?: return
-        MangaFullCoverDialog(this, manga)
-            .showDialog(router)
-    }
-
-    fun shareCover() {
-        try {
-            val manga = manga!!
-            val activity = activity!!
-            useCoverAsBitmap(activity) { coverBitmap ->
-                viewScope.launchIO {
-                    val uri = presenter.saveImage(
-                        image = Image.Cover(
-                            bitmap = coverBitmap,
-                            name = manga.title,
-                            location = Location.Cache,
-                        ),
-                    )
-                    launchUI {
-                        startActivity(uri.toShareIntent(activity))
-                    }
-                }
-            }
-        } catch (e: Throwable) {
-            logcat(LogPriority.ERROR, e)
-            activity?.toast(R.string.error_sharing_cover)
-        }
-    }
-
-    fun saveCover() {
-        try {
-            val manga = manga!!
-            val activity = activity!!
-            useCoverAsBitmap(activity) { coverBitmap ->
-                viewScope.launchIO {
-                    presenter.saveImage(
-                        image = Image.Cover(
-                            bitmap = coverBitmap,
-                            name = manga.title,
-                            location = Location.Pictures.create(),
-                        ),
-                    )
-                    launchUI {
-                        activity.toast(R.string.cover_saved)
-                    }
-                }
-            }
-        } catch (e: Throwable) {
-            logcat(LogPriority.ERROR, e)
-            activity?.toast(R.string.error_saving_cover)
-        }
-    }
-
-    fun changeCover() {
-        val manga = manga ?: return
-        if (manga.hasCustomCover(coverCache)) {
-            ChangeMangaCoverDialog(this, manga).showDialog(router)
-        } else {
-            openMangaCoverPicker(manga)
-        }
-    }
-
-    override fun openMangaCoverPicker(manga: Manga) {
-        if (manga.favorite) {
-            val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
-                type = "image/*"
-            }
-            startActivityForResult(
-                Intent.createChooser(
-                    intent,
-                    resources?.getString(R.string.file_select_cover),
-                ),
-                REQUEST_IMAGE_OPEN,
-            )
-        } else {
-            activity?.toast(R.string.notification_first_add_to_library)
-        }
-
-        destroyActionModeIfNeeded()
-    }
-
-    override fun deleteMangaCover(manga: Manga) {
-        presenter.deleteCustomCover(manga)
-        mangaInfoAdapter?.notifyItemChanged(0, manga)
-        destroyActionModeIfNeeded()
-    }
-
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        if (requestCode == REQUEST_IMAGE_OPEN) {
-            val dataUri = data?.data
-            if (dataUri == null || resultCode != Activity.RESULT_OK) return
-            val activity = activity ?: return
-            presenter.editCover(activity, dataUri)
-        }
-    }
-
-    fun onSetCoverSuccess() {
-        mangaInfoAdapter?.notifyItemChanged(0, this)
-        (router.backstack.lastOrNull()?.controller as? MangaFullCoverDialog)?.setImage(manga)
-        activity?.toast(R.string.cover_updated)
-    }
-
-    fun onSetCoverError(error: Throwable) {
-        activity?.toast(R.string.notification_cover_update_failed)
-        logcat(LogPriority.ERROR, error)
+        val mangaId = manga?.id ?: return
+        router.pushController(MangaFullCoverDialog(mangaId).withFadeTransaction())
     }
 
     /**

+ 0 - 54
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt

@@ -1,7 +1,5 @@
 package eu.kanade.tachiyomi.ui.manga
 
-import android.content.Context
-import android.net.Uri
 import android.os.Bundle
 import com.jakewharton.rxrelay.PublishRelay
 import eu.kanade.domain.category.interactor.GetCategories
@@ -16,13 +14,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.models.MangaCategory
 import eu.kanade.tachiyomi.data.database.models.Track
-import eu.kanade.tachiyomi.data.database.models.toDomainManga
 import eu.kanade.tachiyomi.data.database.models.toMangaInfo
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.data.saver.Image
-import eu.kanade.tachiyomi.data.saver.ImageSaver
 import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.data.track.TrackService
@@ -36,17 +31,14 @@ import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
 import eu.kanade.tachiyomi.util.chapter.getChapterSort
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
-import eu.kanade.tachiyomi.util.editCover
 import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.lang.launchUI
 import eu.kanade.tachiyomi.util.lang.withUIContext
 import eu.kanade.tachiyomi.util.prepUpdateCover
 import eu.kanade.tachiyomi.util.removeCovers
 import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
 import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.util.updateCoverLastModified
 import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.async
@@ -61,7 +53,6 @@ import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
-import uy.kohesive.injekt.injectLazy
 import java.util.Date
 import eu.kanade.domain.category.model.Category as DomainCategory
 
@@ -115,8 +106,6 @@ class MangaPresenter(
 
     private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
 
-    private val imageSaver: ImageSaver by injectLazy()
-
     private var trackSubscription: Subscription? = null
     private var searchTrackerJob: Job? = null
     private var refreshTrackersJob: Job? = null
@@ -295,49 +284,6 @@ class MangaPresenter(
         moveMangaToCategories(manga, listOfNotNull(category))
     }
 
-    /**
-     * Save manga cover Bitmap to picture or temporary share directory.
-     *
-     * @param image the image with specified location
-     * @return flow Flow which emits the Uri which specifies where the image is saved when
-     */
-    fun saveImage(image: Image): Uri {
-        return imageSaver.save(image)
-    }
-
-    /**
-     * Update cover with local file.
-     *
-     * @param context Context.
-     * @param data uri of the cover resource.
-     */
-    fun editCover(context: Context, data: Uri) {
-        presenterScope.launchIO {
-            context.contentResolver.openInputStream(data)?.use {
-                try {
-                    val result = manga.toDomainManga()!!.editCover(context, it)
-                    launchUI { if (result) view?.onSetCoverSuccess() }
-                } catch (e: Exception) {
-                    launchUI { view?.onSetCoverError(e) }
-                }
-            }
-        }
-    }
-
-    fun deleteCustomCover(manga: Manga) {
-        Observable
-            .fromCallable {
-                coverCache.deleteCustomCover(manga.id)
-                manga.updateCoverLastModified(db)
-            }
-            .subscribeOn(Schedulers.io())
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeFirst(
-                { view, _ -> view.onSetCoverSuccess() },
-                { view, e -> view.onSetCoverError(e) },
-            )
-    }
-
     // Manga info - end
 
     // Chapters list - start

+ 217 - 80
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt

@@ -1,118 +1,255 @@
 package eu.kanade.tachiyomi.ui.manga.info
 
-import android.app.Dialog
-import android.graphics.drawable.ColorDrawable
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.BitmapDrawable
+import android.net.Uri
 import android.os.Bundle
-import android.util.TypedValue
-import android.view.View
-import androidx.core.graphics.ColorUtils
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
 import androidx.core.os.bundleOf
-import androidx.core.view.WindowCompat
 import coil.imageLoader
-import coil.request.Disposable
 import coil.request.ImageRequest
-import dev.chrisbanes.insetter.applyInsetter
+import coil.size.Size
+import eu.kanade.domain.manga.interactor.GetMangaById
+import eu.kanade.domain.manga.interactor.UpdateManga
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.hasCustomCover
+import eu.kanade.presentation.manga.EditCoverAction
+import eu.kanade.presentation.manga.components.MangaCoverDialog
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.databinding.MangaFullCoverDialogBinding
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.ui.manga.MangaController
-import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
-import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat
-import eu.kanade.tachiyomi.widget.TachiyomiFullscreenDialog
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.saver.Image
+import eu.kanade.tachiyomi.data.saver.ImageSaver
+import eu.kanade.tachiyomi.data.saver.Location
+import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
+import eu.kanade.tachiyomi.util.editCover
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.launchUI
+import eu.kanade.tachiyomi.util.system.logcat
+import eu.kanade.tachiyomi.util.system.toShareIntent
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
 
-class MangaFullCoverDialog : DialogController {
+class MangaFullCoverDialog : FullComposeController<MangaFullCoverDialog.Presenter> {
 
-    private var manga: Manga? = null
+    private val mangaId: Long
 
-    private var binding: MangaFullCoverDialogBinding? = null
-
-    private var disposable: Disposable? = null
+    @Suppress("unused")
+    constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
 
-    private val mangaController
-        get() = targetController as MangaController?
+    constructor(
+        mangaId: Long,
+    ) : super(bundleOf(MANGA_EXTRA to mangaId)) {
+        this.mangaId = mangaId
+    }
 
-    constructor(targetController: MangaController, manga: Manga) : super(bundleOf("mangaId" to manga.id)) {
-        this.targetController = targetController
-        this.manga = manga
+    override fun createPresenter() = Presenter(mangaId)
+
+    @Composable
+    override fun ComposeContent() {
+        val manga = presenter.manga.collectAsState().value
+        if (manga != null) {
+            MangaCoverDialog(
+                coverDataProvider = { manga },
+                isCustomCover = remember(manga) { manga.hasCustomCover() },
+                onShareClick = this::shareCover,
+                onSaveClick = this::saveCover,
+                onEditClick = this::changeCover,
+                onDismissRequest = router::popCurrentController,
+            )
+        } else {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .background(MaterialTheme.colorScheme.background),
+                contentAlignment = Alignment.Center,
+            ) {
+                CircularProgressIndicator()
+            }
+        }
     }
 
-    @Suppress("unused")
-    constructor(bundle: Bundle) : super(bundle) {
-        val db = Injekt.get<DatabaseHelper>()
-        manga = db.getManga(bundle.getLong("mangaId")).executeAsBlocking()
+    private fun shareCover() {
+        val activity = activity ?: return
+        viewScope.launchIO {
+            try {
+                val uri = presenter.saveCover(activity, temp = true) ?: return@launchIO
+                launchUI {
+                    startActivity(uri.toShareIntent(activity))
+                }
+            } catch (e: Throwable) {
+                launchUI {
+                    logcat(LogPriority.ERROR, e)
+                    activity.toast(R.string.error_saving_cover)
+                }
+            }
+        }
     }
 
-    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-        binding = MangaFullCoverDialogBinding.inflate(activity!!.layoutInflater)
+    private fun saveCover() {
+        val activity = activity ?: return
+        viewScope.launchIO {
+            try {
+                presenter.saveCover(activity, temp = false)
+                launchUI {
+                    activity.toast(R.string.cover_saved)
+                }
+            } catch (e: Throwable) {
+                launchUI {
+                    logcat(LogPriority.ERROR, e)
+                    activity.toast(R.string.error_saving_cover)
+                }
+            }
+        }
+    }
 
-        binding?.toolbar?.apply {
-            setNavigationOnClickListener { dialog?.dismiss() }
-            setOnMenuItemClickListener {
-                when (it.itemId) {
-                    R.id.action_share_cover -> mangaController?.shareCover()
-                    R.id.action_save_cover -> mangaController?.saveCover()
-                    R.id.action_edit_cover -> mangaController?.changeCover()
+    private fun changeCover(action: EditCoverAction) {
+        when (action) {
+            EditCoverAction.EDIT -> {
+                val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+                    type = "image/*"
                 }
-                true
+                startActivityForResult(
+                    Intent.createChooser(
+                        intent,
+                        resources?.getString(R.string.file_select_cover),
+                    ),
+                    REQUEST_IMAGE_OPEN,
+                )
             }
-            menu?.findItem(R.id.action_edit_cover)?.isVisible = manga?.favorite ?: false
+            EditCoverAction.DELETE -> presenter.deleteCustomCover()
         }
+    }
 
-        setImage(manga)
+    private fun onSetCoverSuccess() {
+        activity?.toast(R.string.cover_updated)
+    }
 
-        binding?.appbar?.applyInsetter {
-            type(navigationBars = true, statusBars = true) {
-                padding(left = true, top = true, right = true)
-            }
+    private fun onSetCoverError(error: Throwable) {
+        activity?.toast(R.string.notification_cover_update_failed)
+        logcat(LogPriority.ERROR, error)
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        if (requestCode == REQUEST_IMAGE_OPEN) {
+            val dataUri = data?.data
+            if (dataUri == null || resultCode != Activity.RESULT_OK) return
+            val activity = activity ?: return
+            presenter.editCover(activity, dataUri)
         }
+    }
+
+    class Presenter(
+        private val mangaId: Long,
+        private val getMangaById: GetMangaById = Injekt.get(),
+    ) : nucleus.presenter.Presenter<MangaFullCoverDialog>() {
+
+        private var presenterScope: CoroutineScope = MainScope()
 
-        binding?.container?.onViewClicked = { dialog?.dismiss() }
-        binding?.container?.applyInsetter {
-            type(navigationBars = true) {
-                padding(bottom = true)
+        private val _mangaFlow = MutableStateFlow<Manga?>(null)
+        val manga = _mangaFlow.asStateFlow()
+
+        private val imageSaver by injectLazy<ImageSaver>()
+        private val coverCache by injectLazy<CoverCache>()
+        private val updateManga by injectLazy<UpdateManga>()
+
+        override fun onCreate(savedState: Bundle?) {
+            super.onCreate(savedState)
+            presenterScope.launchIO {
+                getMangaById.subscribe(mangaId)
+                    .collect { _mangaFlow.value = it }
             }
         }
 
-        return TachiyomiFullscreenDialog(activity!!, binding!!.root).apply {
-            val typedValue = TypedValue()
-            val theme = context.theme
-            theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
-            window?.setBackgroundDrawable(ColorDrawable(ColorUtils.setAlphaComponent(typedValue.data, 230)))
+        override fun onDestroy() {
+            super.onDestroy()
+            presenterScope.cancel()
         }
-    }
 
-    override fun onAttach(view: View) {
-        super.onAttach(view)
-        dialog?.window?.let { window ->
-            window.setNavigationBarTransparentCompat(window.context)
-            WindowCompat.setDecorFitsSystemWindows(window, false)
+        /**
+         * Save manga cover Bitmap to picture or temporary share directory.
+         *
+         * @param context The context for building and executing the ImageRequest
+         * @return the uri to saved file
+         */
+        suspend fun saveCover(context: Context, temp: Boolean): Uri? {
+            val manga = manga.value ?: return null
+            val req = ImageRequest.Builder(context)
+                .data(manga)
+                .size(Size.ORIGINAL)
+                .build()
+            val result = context.imageLoader.execute(req).drawable
+
+            // TODO: Handle animated cover
+            val bitmap = (result as? BitmapDrawable)?.bitmap ?: return null
+            return imageSaver.save(
+                Image.Cover(
+                    bitmap = bitmap,
+                    name = manga.title,
+                    location = if (temp) Location.Cache else Location.Pictures.create(),
+                ),
+            )
         }
-    }
 
-    override fun onDetach(view: View) {
-        super.onDetach(view)
-        disposable?.dispose()
-        disposable = null
-    }
+        /**
+         * Update cover with local file.
+         *
+         * @param context Context.
+         * @param data uri of the cover resource.
+         */
+        fun editCover(context: Context, data: Uri) {
+            val manga = manga.value ?: return
+            presenterScope.launchIO {
+                context.contentResolver.openInputStream(data)?.use {
+                    val result = try {
+                        manga.editCover(context, it, updateManga, coverCache)
+                    } catch (e: Exception) {
+                        view?.onSetCoverError(e)
+                        false
+                    }
+                    launchUI { if (result) view?.onSetCoverSuccess() }
+                }
+            }
+        }
 
-    fun setImage(manga: Manga?) {
-        if (manga == null) return
-        val request = ImageRequest.Builder(applicationContext!!)
-            .data(manga)
-            .target {
-                binding?.container?.setImage(
-                    it,
-                    ReaderPageImageView.Config(
-                        zoomDuration = 500,
-                    ),
-                )
+        fun deleteCustomCover() {
+            val mangaId = manga.value?.id ?: return
+            presenterScope.launchIO {
+                try {
+                    coverCache.deleteCustomCover(mangaId)
+                    updateManga.awaitUpdateCoverLastModified(mangaId)
+                    launchUI { view?.onSetCoverSuccess() }
+                } catch (e: Exception) {
+                    launchUI { view?.onSetCoverError(e) }
+                }
             }
-            .build()
+        }
+    }
+
+    companion object {
+        private const val MANGA_EXTRA = "mangaId"
 
-        disposable = applicationContext?.imageLoader?.enqueue(request)
+        /**
+         * Key to change the cover of a manga in [onActivityResult].
+         */
+        private const val REQUEST_IMAGE_OPEN = 101
     }
 }

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

@@ -6,7 +6,6 @@ import android.view.ViewGroup
 import androidx.core.view.isVisible
 import androidx.core.view.updateLayoutParams
 import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@@ -191,36 +190,9 @@ class MangaInfoHeaderAdapter(
                 }
                 .launchIn(controller.viewScope)
 
-            binding.mangaCover.longClicks()
-                .onEach {
-                    showCoverOptionsDialog()
-                }
-                .launchIn(controller.viewScope)
-
             setMangaInfo()
         }
 
-        private fun showCoverOptionsDialog() {
-            val options = listOfNotNull(
-                R.string.action_share,
-                R.string.action_save,
-                // Can only edit cover for library manga
-                if (manga.favorite) R.string.action_edit else null,
-            ).map(controller.activity!!::getString).toTypedArray()
-
-            MaterialAlertDialogBuilder(controller.activity!!)
-                .setTitle(R.string.manga_cover)
-                .setItems(options) { _, item ->
-                    when (item) {
-                        0 -> controller.shareCover()
-                        1 -> controller.saveCover()
-                        2 -> controller.changeCover()
-                    }
-                }
-                .setNegativeButton(android.R.string.cancel, null)
-                .show()
-        }
-
         /**
          * Update the view with manga information.
          *

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt

@@ -34,5 +34,18 @@ class TachiyomiChangeHandlerFrameLayout(
             }
         }
 
+    fun enableScrollingBehavior(enable: Boolean) {
+        (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior = if (enable) {
+            behavior.apply {
+                shouldHeaderOverlap = overlapHeader
+            }
+        } else null
+        if (!enable) {
+            // The behavior doesn't reset translationY when shouldHeaderOverlap is false
+            translationY = 0F
+        }
+        forceLayout()
+    }
+
     override fun getBehavior() = TachiyomiScrollingViewBehavior()
 }