Convert cover dialog view to compose (#7346)

This commit is contained in:
Ivan Iskandar 2022-06-21 09:31:36 +07:00 committed by GitHub
parent cb1830d747
commit 8fedd2d5f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 833 additions and 306 deletions

View File

@ -18,6 +18,10 @@ class MangaRepositoryImpl(
return handler.awaitOne { mangasQueries.getMangaById(id, mangaMapper) } 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>> { override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) } return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,30 @@ import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import nucleus.presenter.Presenter 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. * Compose controller with a Nucleus presenter.
*/ */
@ -97,6 +121,10 @@ abstract class SearchableComposeController<P : BasePresenter<*>>(bundle: Bundle?
} }
} }
interface FullComposeContentController {
@Composable fun ComposeContent()
}
interface ComposeContentController { interface ComposeContentController {
@Composable fun ComposeContent(nestedScrollInterop: NestedScrollConnection) @Composable fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
} }

View File

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

View File

@ -1,11 +1,7 @@
package eu.kanade.tachiyomi.ui.manga package eu.kanade.tachiyomi.ui.manga
import android.app.Activity
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -24,8 +20,6 @@ import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.imageLoader
import coil.request.ImageRequest
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton 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.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
import eu.kanade.tachiyomi.ui.base.controller.pushController 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.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController 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.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog 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.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem 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.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity 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.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat 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.system.toast
import eu.kanade.tachiyomi.util.view.shrinkOnScroll import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
@ -115,7 +104,6 @@ class MangaController :
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemLongClickListener,
BaseChaptersAdapter.OnChapterClickListener, BaseChaptersAdapter.OnChapterClickListener,
ChangeMangaCoverDialog.Listener,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener, DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.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() { fun showFullCoverDialog() {
val manga = manga ?: return val mangaId = manga?.id ?: return
MangaFullCoverDialog(this, manga) router.pushController(MangaFullCoverDialog(mangaId).withFadeTransaction())
.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)
} }
/** /**

View File

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.ui.manga package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.PublishRelay import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.domain.category.interactor.GetCategories 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.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.Track 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.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService 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.getChapterSort
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
import eu.kanade.tachiyomi.util.editCover
import eu.kanade.tachiyomi.util.isLocal import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.launchIO 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.lang.withUIContext
import eu.kanade.tachiyomi.util.prepUpdateCover import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.updateCoverLastModified
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -61,7 +53,6 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
import eu.kanade.domain.category.model.Category as DomainCategory 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 loggedServices by lazy { trackManager.services.filter { it.isLogged } }
private val imageSaver: ImageSaver by injectLazy()
private var trackSubscription: Subscription? = null private var trackSubscription: Subscription? = null
private var searchTrackerJob: Job? = null private var searchTrackerJob: Job? = null
private var refreshTrackersJob: Job? = null private var refreshTrackersJob: Job? = null
@ -295,49 +284,6 @@ class MangaPresenter(
moveMangaToCategories(manga, listOfNotNull(category)) 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 // Manga info - end
// Chapters list - start // Chapters list - start

View File

@ -1,118 +1,255 @@
package eu.kanade.tachiyomi.ui.manga.info package eu.kanade.tachiyomi.ui.manga.info
import android.app.Dialog import android.app.Activity
import android.graphics.drawable.ColorDrawable import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue import androidx.compose.foundation.background
import android.view.View import androidx.compose.foundation.layout.Box
import androidx.core.graphics.ColorUtils 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.os.bundleOf
import androidx.core.view.WindowCompat
import coil.imageLoader import coil.imageLoader
import coil.request.Disposable
import coil.request.ImageRequest 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.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.databinding.MangaFullCoverDialogBinding import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.data.saver.Location
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.util.editCover
import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.widget.TachiyomiFullscreenDialog 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.Injekt
import uy.kohesive.injekt.api.get 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
private val mangaController
get() = targetController as MangaController?
constructor(targetController: MangaController, manga: Manga) : super(bundleOf("mangaId" to manga.id)) {
this.targetController = targetController
this.manga = manga
}
@Suppress("unused") @Suppress("unused")
constructor(bundle: Bundle) : super(bundle) { constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
val db = Injekt.get<DatabaseHelper>()
manga = db.getManga(bundle.getLong("mangaId")).executeAsBlocking() constructor(
mangaId: Long,
) : super(bundleOf(MANGA_EXTRA to mangaId)) {
this.mangaId = mangaId
} }
override fun onCreateDialog(savedViewState: Bundle?): Dialog { override fun createPresenter() = Presenter(mangaId)
binding = MangaFullCoverDialogBinding.inflate(activity!!.layoutInflater)
binding?.toolbar?.apply { @Composable
setNavigationOnClickListener { dialog?.dismiss() } override fun ComposeContent() {
setOnMenuItemClickListener { val manga = presenter.manga.collectAsState().value
when (it.itemId) { if (manga != null) {
R.id.action_share_cover -> mangaController?.shareCover() MangaCoverDialog(
R.id.action_save_cover -> mangaController?.saveCover() coverDataProvider = { manga },
R.id.action_edit_cover -> mangaController?.changeCover() 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()
} }
true
}
menu?.findItem(R.id.action_edit_cover)?.isVisible = manga?.favorite ?: false
}
setImage(manga)
binding?.appbar?.applyInsetter {
type(navigationBars = true, statusBars = true) {
padding(left = true, top = true, right = true)
} }
} }
binding?.container?.onViewClicked = { dialog?.dismiss() } private fun shareCover() {
binding?.container?.applyInsetter { val activity = activity ?: return
type(navigationBars = true) { viewScope.launchIO {
padding(bottom = true) 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)
}
}
} }
} }
return TachiyomiFullscreenDialog(activity!!, binding!!.root).apply { private fun saveCover() {
val typedValue = TypedValue() val activity = activity ?: return
val theme = context.theme viewScope.launchIO {
theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true) try {
window?.setBackgroundDrawable(ColorDrawable(ColorUtils.setAlphaComponent(typedValue.data, 230))) 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)
}
}
} }
} }
override fun onAttach(view: View) { private fun changeCover(action: EditCoverAction) {
super.onAttach(view) when (action) {
dialog?.window?.let { window -> EditCoverAction.EDIT -> {
window.setNavigationBarTransparentCompat(window.context) val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
WindowCompat.setDecorFitsSystemWindows(window, false) type = "image/*"
}
startActivityForResult(
Intent.createChooser(
intent,
resources?.getString(R.string.file_select_cover),
),
REQUEST_IMAGE_OPEN,
)
}
EditCoverAction.DELETE -> presenter.deleteCustomCover()
} }
} }
override fun onDetach(view: View) { private fun onSetCoverSuccess() {
super.onDetach(view) activity?.toast(R.string.cover_updated)
disposable?.dispose()
disposable = null
} }
fun setImage(manga: Manga?) { private fun onSetCoverError(error: Throwable) {
if (manga == null) return activity?.toast(R.string.notification_cover_update_failed)
val request = ImageRequest.Builder(applicationContext!!) 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()
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 }
}
}
override fun onDestroy() {
super.onDestroy()
presenterScope.cancel()
}
/**
* 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) .data(manga)
.target { .size(Size.ORIGINAL)
binding?.container?.setImage( .build()
it, val result = context.imageLoader.execute(req).drawable
ReaderPageImageView.Config(
zoomDuration = 500, // 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(),
), ),
) )
} }
.build()
disposable = applicationContext?.imageLoader?.enqueue(request) /**
* 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 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) }
}
}
}
}
companion object {
private const val MANGA_EXTRA = "mangaId"
/**
* Key to change the cover of a manga in [onActivityResult].
*/
private const val REQUEST_IMAGE_OPEN = 101
} }
} }

View File

@ -6,7 +6,6 @@ import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -191,36 +190,9 @@ class MangaInfoHeaderAdapter(
} }
.launchIn(controller.viewScope) .launchIn(controller.viewScope)
binding.mangaCover.longClicks()
.onEach {
showCoverOptionsDialog()
}
.launchIn(controller.viewScope)
setMangaInfo() 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. * Update the view with manga information.
* *

View File

@ -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() override fun getBehavior() = TachiyomiScrollingViewBehavior()
} }