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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -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 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
}
private val mangaId: Long
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val db = Injekt.get<DatabaseHelper>()
manga = db.getManga(bundle.getLong("mangaId")).executeAsBlocking()
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
constructor(
mangaId: Long,
) : super(bundleOf(MANGA_EXTRA to mangaId)) {
this.mangaId = mangaId
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
binding = MangaFullCoverDialogBinding.inflate(activity!!.layoutInflater)
override fun createPresenter() = Presenter(mangaId)
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()
@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()
}
}
}
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)
}
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() }
binding?.container?.applyInsetter {
type(navigationBars = true) {
padding(bottom = true)
}
}
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 onAttach(view: View) {
super.onAttach(view)
dialog?.window?.let { window ->
window.setNavigationBarTransparentCompat(window.context)
WindowCompat.setDecorFitsSystemWindows(window, false)
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)
}
}
}
}
override fun onDetach(view: View) {
super.onDetach(view)
disposable?.dispose()
disposable = null
}
fun setImage(manga: Manga?) {
if (manga == null) return
val request = ImageRequest.Builder(applicationContext!!)
.data(manga)
.target {
binding?.container?.setImage(
it,
ReaderPageImageView.Config(
zoomDuration = 500,
private fun changeCover(action: EditCoverAction) {
when (action) {
EditCoverAction.EDIT -> {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "image/*"
}
startActivityForResult(
Intent.createChooser(
intent,
resources?.getString(R.string.file_select_cover),
),
REQUEST_IMAGE_OPEN,
)
}
.build()
EditCoverAction.DELETE -> presenter.deleteCustomCover()
}
}
disposable = applicationContext?.imageLoader?.enqueue(request)
private fun onSetCoverSuccess() {
activity?.toast(R.string.cover_updated)
}
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()
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)
.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(),
),
)
}
/**
* 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.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.
*

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