MangaController overhaul (#7244)

(cherry picked from commit 33a778873af0eb52528a29f741fa59b530679c64)

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt
#	app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt
#	app/src/main/res/layout-sw720dp/manga_info_header.xml
#	app/src/main/res/layout/manga_controller.xml
#	app/src/main/res/layout/manga_info_header.xml
#	app/src/main/res/layout/manga_summary.xml
#	app/src/main/res/menu/manga.xml
This commit is contained in:
Ivan Iskandar 2022-06-25 22:03:48 +07:00 committed by Jobobby04
parent 1e53ad97db
commit 2b7aca710e
106 changed files with 5016 additions and 4705 deletions

View File

@ -133,13 +133,16 @@ dependencies {
implementation(compose.activity)
implementation(compose.foundation)
implementation(compose.material3.core)
implementation(compose.material3.windowsizeclass)
implementation(compose.material3.adapter)
implementation(compose.material.icons)
implementation(compose.animation)
implementation(compose.animation.graphics)
implementation(compose.ui.tooling)
implementation(compose.ui.util)
implementation(compose.accompanist.webview)
implementation(compose.accompanist.swiperefresh)
implementation(compose.accompanist.flowlayout)
implementation(androidx.paging.runtime)
implementation(androidx.paging.compose)
@ -306,7 +309,8 @@ tasks {
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
)
}

View File

@ -0,0 +1,29 @@
package eu.kanade.data.manga
import eu.kanade.data.DatabaseHandler
import eu.kanade.data.exh.mergedMangaReferenceMapper
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.repository.MangaMergeRepository
import exh.merged.sql.models.MergedMangaReference
import kotlinx.coroutines.flow.Flow
class MangaMergeRepositoryImpl(
private val handler: DatabaseHandler,
) : MangaMergeRepository {
override suspend fun getMergedMangaById(id: Long): List<Manga> {
return handler.awaitList { mergedQueries.selectMergedMangasById(id, mangaMapper) }
}
override suspend fun subscribeMergedMangaById(id: Long): Flow<List<Manga>> {
return handler.subscribeToList { mergedQueries.selectMergedMangasById(id, mangaMapper) }
}
override suspend fun getReferencesById(id: Long): List<MergedMangaReference> {
return handler.awaitList { mergedQueries.selectByMergeId(id, mergedMangaReferenceMapper) }
}
override suspend fun subscribeReferencesById(id: Long): Flow<List<MergedMangaReference>> {
return handler.subscribeToList { mergedQueries.selectByMergeId(id, mergedMangaReferenceMapper) }
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.data.manga
import eu.kanade.data.DatabaseHandler
import eu.kanade.data.exh.searchMetadataMapper
import eu.kanade.data.exh.searchTagMapper
import eu.kanade.data.exh.searchTitleMapper
import eu.kanade.domain.manga.repository.MangaMetadataRepository
import exh.metadata.sql.models.SearchMetadata
import exh.metadata.sql.models.SearchTag
import exh.metadata.sql.models.SearchTitle
import kotlinx.coroutines.flow.Flow
class MangaMetadataRepositoryImpl(
private val handler: DatabaseHandler,
) : MangaMetadataRepository {
override suspend fun getMetadataById(id: Long): SearchMetadata? {
return handler.awaitOneOrNull { search_metadataQueries.selectByMangaId(id, searchMetadataMapper) }
}
override suspend fun subscribeMetadataById(id: Long): Flow<SearchMetadata?> {
return handler.subscribeToOneOrNull { search_metadataQueries.selectByMangaId(id, searchMetadataMapper) }
}
override suspend fun getTagsById(id: Long): List<SearchTag> {
return handler.awaitList { search_tagsQueries.selectByMangaId(id, searchTagMapper) }
}
override suspend fun subscribeTagsById(id: Long): Flow<List<SearchTag>> {
return handler.subscribeToList { search_tagsQueries.selectByMangaId(id, searchTagMapper) }
}
override suspend fun getTitlesById(id: Long): List<SearchTitle> {
return handler.awaitList { search_titlesQueries.selectByMangaId(id, searchTitleMapper) }
}
override suspend fun subscribeTitlesById(id: Long): Flow<List<SearchTitle>> {
return handler.subscribeToList { search_titlesQueries.selectByMangaId(id, searchTitleMapper) }
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.data.manga
import eu.kanade.data.DatabaseHandler
import eu.kanade.data.listOfStringsAdapter
import eu.kanade.data.listOfStringsAndAdapter
import eu.kanade.data.toLong
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
@ -22,6 +23,10 @@ class MangaRepositoryImpl(
return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
}
override suspend fun getMangaByIdAsFlow(id: Long): Flow<Manga> {
return handler.subscribeToOne { mangasQueries.getMangaById(id, mangaMapper) }
}
override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
return handler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) }
}
@ -72,6 +77,7 @@ class MangaRepositoryImpl(
coverLastModified = update.coverLastModified,
dateAdded = update.dateAdded,
mangaId = update.id,
filteredScanlators = update.filteredScanlators?.let(listOfStringsAndAdapter::encode),
)
}
true

View File

@ -33,6 +33,7 @@ import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
import eu.kanade.domain.manga.interactor.GetMangaById
import eu.kanade.domain.manga.interactor.GetMangaWithChapters
import eu.kanade.domain.manga.interactor.ResetViewerFlags
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.domain.source.interactor.GetEnabledSources
@ -71,6 +72,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaById(get()) }
addFactory { GetNextChapter(get()) }
addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { UpdateManga(get()) }
addFactory { MoveMangaToCategories(get()) }

View File

@ -1,6 +1,14 @@
package eu.kanade.domain
import eu.kanade.data.manga.MangaMergeRepositoryImpl
import eu.kanade.data.manga.MangaMetadataRepositoryImpl
import eu.kanade.domain.chapter.interactor.GetMergedChapterByMangaId
import eu.kanade.domain.manga.interactor.GetFlatMetadataById
import eu.kanade.domain.manga.interactor.GetMergedMangaById
import eu.kanade.domain.manga.interactor.GetMergedReferencesById
import eu.kanade.domain.manga.interactor.SetMangaFilteredScanlators
import eu.kanade.domain.manga.repository.MangaMergeRepository
import eu.kanade.domain.manga.repository.MangaMetadataRepository
import eu.kanade.domain.source.interactor.GetShowLatest
import eu.kanade.domain.source.interactor.GetSourceCategories
import eu.kanade.domain.source.interactor.SetSourceCategories
@ -9,6 +17,7 @@ import eu.kanade.domain.source.interactor.ToggleSources
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
class SYDomainModule : InjektModule {
@ -19,6 +28,14 @@ class SYDomainModule : InjektModule {
addFactory { ToggleExcludeFromDataSaver(get()) }
addFactory { SetSourceCategories(get()) }
addFactory { ToggleSources(get()) }
addFactory { SetMangaFilteredScanlators(get()) }
addSingletonFactory<MangaMetadataRepository> { MangaMetadataRepositoryImpl(get()) }
addFactory { GetFlatMetadataById(get()) }
addSingletonFactory<MangaMergeRepository> { MangaMergeRepositoryImpl(get()) }
addFactory { GetMergedMangaById(get()) }
addFactory { GetMergedReferencesById(get()) }
addFactory { GetMergedChapterByMangaId(get()) }
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.repository.MangaMetadataRepository
import eu.kanade.tachiyomi.util.system.logcat
import exh.metadata.metadata.base.FlatMetadata
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import logcat.LogPriority
class GetFlatMetadataById(
private val mangaMetadataRepository: MangaMetadataRepository,
) {
suspend fun await(id: Long): FlatMetadata? {
return try {
val meta = mangaMetadataRepository.getMetadataById(id)
return if (meta != null) {
val tags = mangaMetadataRepository.getTagsById(id)
val titles = mangaMetadataRepository.getTitlesById(id)
FlatMetadata(meta, tags, titles)
} else null
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
null
}
}
suspend fun subscribe(id: Long): Flow<FlatMetadata?> {
return combine(
mangaMetadataRepository.subscribeMetadataById(id),
mangaMetadataRepository.subscribeTagsById(id),
mangaMetadataRepository.subscribeTitlesById(id),
) { meta, tags, titles ->
if (meta != null) {
FlatMetadata(meta, tags, titles)
} else null
}
}
}

View File

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

View File

@ -0,0 +1,25 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.repository.MangaMergeRepository
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
class GetMergedMangaById(
private val mangaMergeRepository: MangaMergeRepository,
) {
suspend fun await(id: Long): List<Manga> {
return try {
mangaMergeRepository.getMergedMangaById(id)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
emptyList()
}
}
suspend fun subscribe(id: Long): Flow<List<Manga>> {
return mangaMergeRepository.subscribeMergedMangaById(id)
}
}

View File

@ -0,0 +1,25 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.repository.MangaMergeRepository
import eu.kanade.tachiyomi.util.system.logcat
import exh.merged.sql.models.MergedMangaReference
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
class GetMergedReferencesById(
private val mangaMergeRepository: MangaMergeRepository,
) {
suspend fun await(id: Long): List<MergedMangaReference> {
return try {
mangaMergeRepository.getReferencesById(id)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
emptyList()
}
}
suspend fun subscribe(id: Long): Flow<List<MergedMangaReference>> {
return mangaMergeRepository.subscribeReferencesById(id)
}
}

View File

@ -0,0 +1,95 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.repository.MangaRepository
class SetMangaChapterFlags(private val mangaRepository: MangaRepository) {
suspend fun awaitSetDownloadedFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DOWNLOADED_MASK),
),
)
}
suspend fun awaitSetUnreadFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_UNREAD_MASK),
),
)
}
suspend fun awaitSetBookmarkFilter(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_BOOKMARKED_MASK),
),
)
}
suspend fun awaitSetDisplayMode(manga: Manga, flag: Long): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = manga.chapterFlags.setFlag(flag, Manga.CHAPTER_DISPLAY_MASK),
),
)
}
suspend fun awaitSetSortingModeOrFlipOrder(manga: Manga, flag: Long): Boolean {
val newFlags = manga.chapterFlags.let {
if (manga.sorting == flag) {
// Just flip the order
val orderFlag = if (manga.sortDescending()) {
Manga.CHAPTER_SORT_ASC
} else {
Manga.CHAPTER_SORT_DESC
}
it.setFlag(orderFlag, Manga.CHAPTER_SORT_DIR_MASK)
} else {
// Set new flag with ascending order
it
.setFlag(flag, Manga.CHAPTER_SORTING_MASK)
.setFlag(Manga.CHAPTER_SORT_ASC, Manga.CHAPTER_SORT_DIR_MASK)
}
}
return mangaRepository.update(
MangaUpdate(
id = manga.id,
chapterFlags = newFlags,
),
)
}
suspend fun awaitSetAllFlags(
mangaId: Long,
unreadFilter: Long,
downloadedFilter: Long,
bookmarkedFilter: Long,
sortingMode: Long,
sortingDirection: Long,
displayMode: Long,
): Boolean {
return mangaRepository.update(
MangaUpdate(
id = mangaId,
chapterFlags = 0L.setFlag(unreadFilter, Manga.CHAPTER_UNREAD_MASK)
.setFlag(downloadedFilter, Manga.CHAPTER_DOWNLOADED_MASK)
.setFlag(bookmarkedFilter, Manga.CHAPTER_BOOKMARKED_MASK)
.setFlag(sortingMode, Manga.CHAPTER_SORTING_MASK)
.setFlag(sortingDirection, Manga.CHAPTER_SORT_DIR_MASK)
.setFlag(displayMode, Manga.CHAPTER_DISPLAY_MASK),
),
)
}
private fun Long.setFlag(flag: Long, mask: Long): Long {
return this and mask.inv() or (flag and mask)
}
}

View File

@ -0,0 +1,17 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.repository.MangaRepository
class SetMangaFilteredScanlators(private val mangaRepository: MangaRepository) {
suspend fun awaitSetFilteredScanlators(manga: Manga, filteredScanlators: List<String>): Boolean {
return mangaRepository.update(
MangaUpdate(
id = manga.id,
filteredScanlators = filteredScanlators,
),
)
}
}

View File

@ -8,6 +8,8 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.tachiyomi.data.cache.CoverCache
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class UpdateManga(
@ -22,7 +24,7 @@ class UpdateManga(
localManga: Manga,
remoteManga: MangaInfo,
manualFetch: Boolean,
coverCache: CoverCache,
coverCache: CoverCache = Injekt.get(),
): Boolean {
// if the manga isn't a favorite, set its title from source and update in db
val title = if (!localManga.favorite) remoteManga.title else null
@ -66,4 +68,14 @@ class UpdateManga(
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Date().time))
}
suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
val dateAdded = when (favorite) {
true -> Date().time
false -> 0
}
return mangaRepository.update(
MangaUpdate(id = mangaId, favorite = favorite, dateAdded = dateAdded),
)
}
}

View File

@ -18,4 +18,7 @@ data class MangaUpdate(
val status: Long? = null,
val thumbnailUrl: String? = null,
val initialized: Boolean? = null,
// SY -->
val filteredScanlators: List<String>? = null,
// SY <--
)

View File

@ -0,0 +1,15 @@
package eu.kanade.domain.manga.repository
import eu.kanade.domain.manga.model.Manga
import exh.merged.sql.models.MergedMangaReference
import kotlinx.coroutines.flow.Flow
interface MangaMergeRepository {
suspend fun getMergedMangaById(id: Long): List<Manga>
suspend fun subscribeMergedMangaById(id: Long): Flow<List<Manga>>
suspend fun getReferencesById(id: Long): List<MergedMangaReference>
suspend fun subscribeReferencesById(id: Long): Flow<List<MergedMangaReference>>
}

View File

@ -0,0 +1,20 @@
package eu.kanade.domain.manga.repository
import exh.metadata.sql.models.SearchMetadata
import exh.metadata.sql.models.SearchTag
import exh.metadata.sql.models.SearchTitle
import kotlinx.coroutines.flow.Flow
interface MangaMetadataRepository {
suspend fun getMetadataById(id: Long): SearchMetadata?
suspend fun subscribeMetadataById(id: Long): Flow<SearchMetadata?>
suspend fun getTagsById(id: Long): List<SearchTag>
suspend fun subscribeTagsById(id: Long): Flow<List<SearchTag>>
suspend fun getTitlesById(id: Long): List<SearchTitle>
suspend fun subscribeTitlesById(id: Long): Flow<List<SearchTitle>>
}

View File

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

View File

@ -0,0 +1,101 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun TextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = null,
shape: Shape = Shapes.Full,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.textButtonColors(),
contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding,
content: @Composable RowScope.() -> Unit,
) =
Button(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
interactionSource = interactionSource,
elevation = elevation,
shape = shape,
border = border,
colors = colors,
contentPadding = contentPadding,
content = content,
)
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
shape: Shape = Shapes.Full,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit,
) {
val containerColor = colors.containerColor(enabled).value
val contentColor = colors.contentColor(enabled).value
val shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
val tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp
Surface(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
interactionSource = interactionSource,
enabled = enabled,
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
Row(
Modifier.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight,
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}
}
}

View File

@ -0,0 +1,245 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ChipBorder
import androidx.compose.material3.ChipColors
import androidx.compose.material3.ChipElevation
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ExperimentalMaterial3Api
@Composable
fun SuggestionChip(
label: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ChipElevation? = SuggestionChipDefaults.suggestionChipElevation(),
shape: Shape = MaterialTheme.shapes.small,
border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(),
colors: ChipColors = SuggestionChipDefaults.suggestionChipColors(),
) = Chip(
modifier = modifier,
label = label,
labelTextStyle = MaterialTheme.typography.labelLarge,
labelColor = colors.labelColor(enabled).value,
leadingIcon = icon,
avatar = null,
trailingIcon = null,
leadingIconColor = colors.leadingIconContentColor(enabled).value,
trailingIconColor = colors.trailingIconContentColor(enabled).value,
containerColor = colors.containerColor(enabled).value,
tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp,
shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp,
minHeight = SuggestionChipDefaults.Height,
paddingValues = SuggestionChipPadding,
shape = shape,
border = border?.borderStroke(enabled)?.value,
)
@ExperimentalMaterial3Api
@Composable
fun SuggestionChip(
onClick: () -> Unit,
onLongClick: () -> Unit,
label: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ChipElevation? = SuggestionChipDefaults.suggestionChipElevation(),
shape: Shape = MaterialTheme.shapes.small,
border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(),
colors: ChipColors = SuggestionChipDefaults.suggestionChipColors(),
) = Chip(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
label = label,
labelTextStyle = MaterialTheme.typography.labelLarge,
labelColor = colors.labelColor(enabled).value,
leadingIcon = icon,
avatar = null,
trailingIcon = null,
leadingIconColor = colors.leadingIconContentColor(enabled).value,
trailingIconColor = colors.trailingIconContentColor(enabled).value,
containerColor = colors.containerColor(enabled).value,
tonalElevation = elevation?.tonalElevation(enabled, interactionSource)?.value ?: 0.dp,
shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp,
minHeight = SuggestionChipDefaults.Height,
paddingValues = SuggestionChipPadding,
shape = shape,
border = border?.borderStroke(enabled)?.value,
interactionSource = interactionSource,
)
@Suppress("SameParameterValue")
@ExperimentalMaterial3Api
@Composable
private fun Chip(
modifier: Modifier,
label: @Composable () -> Unit,
labelTextStyle: TextStyle,
labelColor: Color,
leadingIcon: @Composable (() -> Unit)?,
avatar: @Composable (() -> Unit)?,
trailingIcon: @Composable (() -> Unit)?,
leadingIconColor: Color,
trailingIconColor: Color,
containerColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
minHeight: Dp,
paddingValues: PaddingValues,
shape: Shape,
border: BorderStroke?,
) {
Surface(
modifier = modifier,
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
) {
ChipContent(
label = label,
labelTextStyle = labelTextStyle,
labelColor = labelColor,
leadingIcon = leadingIcon,
avatar = avatar,
trailingIcon = trailingIcon,
leadingIconColor = leadingIconColor,
trailingIconColor = trailingIconColor,
minHeight = minHeight,
paddingValues = paddingValues,
)
}
}
@Suppress("SameParameterValue")
@ExperimentalMaterial3Api
@Composable
private fun Chip(
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
enabled: Boolean,
label: @Composable () -> Unit,
labelTextStyle: TextStyle,
labelColor: Color,
leadingIcon: @Composable (() -> Unit)?,
avatar: @Composable (() -> Unit)?,
trailingIcon: @Composable (() -> Unit)?,
leadingIconColor: Color,
trailingIconColor: Color,
containerColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
minHeight: Dp,
paddingValues: PaddingValues,
shape: Shape,
border: BorderStroke?,
interactionSource: MutableInteractionSource,
) {
Surface(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
interactionSource = interactionSource,
) {
ChipContent(
label = label,
labelTextStyle = labelTextStyle,
labelColor = labelColor,
leadingIcon = leadingIcon,
avatar = avatar,
trailingIcon = trailingIcon,
leadingIconColor = leadingIconColor,
trailingIconColor = trailingIconColor,
minHeight = minHeight,
paddingValues = paddingValues,
)
}
}
@Composable
private fun ChipContent(
label: @Composable () -> Unit,
labelTextStyle: TextStyle,
labelColor: Color,
leadingIcon: @Composable (() -> Unit)?,
avatar: @Composable (() -> Unit)?,
trailingIcon: @Composable (() -> Unit)?,
leadingIconColor: Color,
trailingIconColor: Color,
minHeight: Dp,
paddingValues: PaddingValues,
) {
CompositionLocalProvider(
LocalContentColor provides labelColor,
LocalTextStyle provides labelTextStyle,
) {
Row(
Modifier.defaultMinSize(minHeight = minHeight).padding(paddingValues),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
if (avatar != null) {
avatar()
} else if (leadingIcon != null) {
CompositionLocalProvider(
LocalContentColor provides leadingIconColor, content = leadingIcon,
)
}
Spacer(Modifier.width(HorizontalElementsPadding))
label()
Spacer(Modifier.width(HorizontalElementsPadding))
if (trailingIcon != null) {
CompositionLocalProvider(
LocalContentColor provides trailingIconColor, content = trailingIcon,
)
}
}
}
}
/**
* The padding between the elements in the chip.
*/
private val HorizontalElementsPadding = 8.dp
/**
* Returns the [PaddingValues] for the suggestion chip.
*/
private val SuggestionChipPadding = PaddingValues(horizontal = HorizontalElementsPadding)

View File

@ -0,0 +1,111 @@
package eu.kanade.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun ExtendedFloatingActionButton(
text: @Composable () -> Unit,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = MaterialTheme.shapes.large,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
) {
val minWidth by animateDpAsState(if (expanded) ExtendedFabMinimumWidth else FabContainerWidth)
FloatingActionButton(
modifier = modifier.sizeIn(minWidth = minWidth),
onClick = onClick,
interactionSource = interactionSource,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
elevation = elevation,
) {
val startPadding by animateDpAsState(if (expanded) ExtendedFabIconSize / 2 else 0.dp)
val endPadding by animateDpAsState(if (expanded) ExtendedFabTextPadding else 0.dp)
Row(
modifier = Modifier.padding(start = startPadding, end = endPadding),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
AnimatedVisibility(
visible = expanded,
enter = ExtendedFabExpandAnimation,
exit = ExtendedFabCollapseAnimation,
) {
Row {
Spacer(Modifier.width(ExtendedFabIconPadding))
text()
}
}
}
}
}
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
private val ExtendedFabMinimumWidth = 80.dp
private val ExtendedFabIconSize = 24.0.dp
private val ExtendedFabIconPadding = 12.dp
private val ExtendedFabTextPadding = 20.dp
private val ExtendedFabCollapseAnimation = fadeOut(
animationSpec = tween(
durationMillis = 100,
easing = EasingLinearCubicBezier,
),
) + shrinkHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
shrinkTowards = Alignment.Start,
)
private val ExtendedFabExpandAnimation = fadeIn(
animationSpec = tween(
durationMillis = 200,
delayMillis = 100,
easing = EasingLinearCubicBezier,
),
) + expandHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
expandFrom = Alignment.Start,
)
private val FabContainerWidth = 56.0.dp

View File

@ -0,0 +1,108 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.minimumTouchTargetSize
import kotlin.math.ln
@Composable
@NonRestartableComposable
fun Surface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = Shapes.None,
color: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(color),
tonalElevation: Dp = 0.dp,
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalAbsoluteTonalElevation provides absoluteElevation,
) {
Box(
modifier = modifier
.minimumTouchTargetSize()
.surface(
shape = shape,
backgroundColor = surfaceColorAtElevation(
color = color,
elevation = absoluteElevation,
),
border = border,
shadowElevation = shadowElevation,
)
.combinedClickable(
interactionSource = interactionSource,
indication = rememberRipple(),
enabled = enabled,
role = Role.Button,
onLongClick = onLongClick,
onClick = onClick,
),
propagateMinConstraints = true,
) {
content()
}
}
}
private fun Modifier.surface(
shape: Shape,
backgroundColor: Color,
border: BorderStroke?,
shadowElevation: Dp,
) = this
.shadow(shadowElevation, shape, clip = false)
.then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(color = backgroundColor, shape = shape)
.clip(shape)
@Composable
@ReadOnlyComposable
private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
return if (color == MaterialTheme.colorScheme.surface) {
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
} else {
color
}
}
private fun ColorScheme.surfaceColorAtElevation(
elevation: Dp,
): Color {
if (elevation == 0.dp) return surface
val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
return surfaceTint.copy(alpha = alpha).compositeOver(surface)
}

View File

@ -0,0 +1,887 @@
package eu.kanade.presentation.manga
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberTopAppBarScrollState
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.manga.model.Manga.Companion.CHAPTER_DISPLAY_NUMBER
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.VerticalFastScroller
import eu.kanade.presentation.manga.components.ChapterHeader
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
import eu.kanade.presentation.manga.components.MangaChapterListItem
import eu.kanade.presentation.manga.components.MangaInfoHeader
import eu.kanade.presentation.manga.components.MangaSmallAppBar
import eu.kanade.presentation.manga.components.MangaTopAppBar
import eu.kanade.presentation.manga.components.SearchMetadataChips
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior
import eu.kanade.presentation.util.isScrolledToEnd
import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.ui.manga.ChapterItem
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.lang.toRelativeString
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import kotlinx.coroutines.runBlocking
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Date
private val chapterDecimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
@Composable
fun MangaScreen(
state: MangaScreenState.Success,
snackbarHostState: SnackbarHostState,
windowWidthSizeClass: WindowWidthSizeClass,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onMetadataViewerClicked: () -> Unit,
onEditInfoClicked: () -> Unit,
onRecommendClicked: () -> Unit,
onMergedSettingsClicked: () -> Unit,
onMergeClicked: () -> Unit,
onMergeWithAnotherClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
MangaScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueReading,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
onMetadataViewerClicked = onMetadataViewerClicked,
onEditInfoClicked = onEditInfoClicked,
onRecommendClicked = onRecommendClicked,
onMergedSettingsClicked = onMergedSettingsClicked,
onMergeClicked = onMergeClicked,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
} else {
MangaScreenLargeImpl(
state = state,
windowWidthSizeClass = windowWidthSizeClass,
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueReading,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
onDownloadActionClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
onMetadataViewerClicked = onMetadataViewerClicked,
onEditInfoClicked = onEditInfoClicked,
onRecommendClicked = onRecommendClicked,
onMergedSettingsClicked = onMergedSettingsClicked,
onMergeClicked = onMergeClicked,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
}
}
@Composable
private fun MangaScreenSmallImpl(
state: MangaScreenState.Success,
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onMetadataViewerClicked: () -> Unit,
onEditInfoClicked: () -> Unit,
onRecommendClicked: () -> Unit,
onMergedSettingsClicked: () -> Unit,
onMergeClicked: () -> Unit,
onMergeWithAnotherClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val haptic = LocalHapticFeedback.current
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec)
val chapterListState = rememberLazyListState()
SideEffect {
if (chapterListState.firstVisibleItemIndex > 0 || chapterListState.firstVisibleItemScrollOffset > 0) {
// Should go here after a configuration change
// Safe to say that the app bar is fully scrolled
scrollBehavior.state.offset = scrollBehavior.state.offsetLimit
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
onRefresh = onRefresh,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(LocalDensity.current) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val chapters = remember(state) { state.processedChapters.toList() }
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(insetPadding),
topBar = {
MangaTopAppBar(
modifier = Modifier
.scrollable(
state = rememberScrollableState {
var consumed = runBlocking { chapterListState.scrollBy(-it) } * -1
if (consumed == 0f) {
// Pass scroll to app bar if we're on the top of the list
val newOffset =
(scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f)
consumed = newOffset - scrollBehavior.state.offset
scrollBehavior.state.offset = newOffset
}
consumed
},
orientation = Orientation.Vertical,
interactionSource = chapterListState.interactionSource as MutableInteractionSource,
),
title = state.manga.title,
author = state.manga.author,
artist = state.manga.artist,
description = state.manga.description,
tagsProvider = { state.manga.genre },
coverDataProvider = { state.manga },
sourceName = remember { state.source.getNameForMangaInfo() },
isStubSource = remember { state.source is SourceManager.StubSource },
favorite = state.manga.favorite,
status = state.manga.status,
trackingCount = state.trackingCount,
chapterCount = chapters.size,
chapterFiltered = state.manga.chaptersFiltered(),
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
fromSource = state.isFromSource,
onBackClicked = internalOnBackPressed,
onCoverClick = onCoverClicked,
onTagClicked = onTagClicked,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onFilterButtonClicked = onFilterButtonClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
onEditInfoClicked = onEditInfoClicked,
onRecommendClicked = onRecommendClicked,
showRecommendsInOverflow = state.showRecommendationsInOverflow,
showMergedSettings = state.manga.source == MERGED_SOURCE_ID,
onMergedSettingsClicked = onMergedSettingsClicked,
onMergeClicked = onMergeClicked,
doGlobalSearch = onSearch,
showMergeWithAnother = state.showMergeWithAnother,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
mangaMetadataHeader = remember {
getDescriptionComposable(
source = state.source.getMainSource<MetadataSource<*, *>>(),
state = state,
openMetadataViewer = onMetadataViewerClicked,
search = { onSearch(it, false) },
)
},
searchMetadataChips = remember { SearchMetadataChips(state.meta, state.source, state.manga.genre) },
scrollBehavior = scrollBehavior,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(chapters)
},
onInvertSelection = {
val toSelect = chapters - selected
selected.clear()
selected.addAll(toSelect)
},
onSmallAppBarHeightChanged = onTopBarHeightChanged,
)
},
bottomBar = {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.all { it.chapter.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.any { it.chapter.read } },
onMarkPreviousAsReadClicked = {
onMarkPreviousAsReadClicked(selected[0].chapter)
selected.clear()
}.takeIf { selected.size == 1 },
onDownloadClicked = {
onDownloadChapter!!(selected, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.chapter })
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
},
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (chapters.any { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
}
Text(text = stringResource(id = id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
)
}
},
) { contentPadding ->
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
VerticalFastScroller(
listState = chapterListState,
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = withNavBarContentPadding,
) {
items(items = chapters) { chapterItem ->
val (chapter, downloadState, downloadProgress) = chapterItem
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
} else {
chapter.name
}
}
val date = remember(chapter.dateUpload) {
chapter.dateUpload
.takeIf { it > 0 }
?.let { Date(it).toRelativeString(context, state.dateRelativeTime, state.dateFormat) }
}
val lastPageRead = remember(chapter.lastPageRead) {
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
}
val scanlator = remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
MangaChapterListItem(
title = chapterTitle,
date = date,
readProgress = lastPageRead?.let { stringResource(id = R.string.chapter_progress, it + 1) },
scanlator = scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
selected = selected.contains(chapterItem),
downloadState = downloadState,
downloadProgress = downloadProgress,
onLongClick = {
val dispatched = onChapterItemLongClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onChapterItemClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
onChapterClicked = onChapterClicked,
)
},
onDownloadClick = if (onDownloadChapter != null) {
{ onDownloadChapter(listOf(chapterItem), it) }
} else null,
)
}
}
}
}
}
}
@Composable
fun MangaScreenLargeImpl(
state: MangaScreenState.Success,
windowWidthSizeClass: WindowWidthSizeClass,
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
onCoverClicked: () -> Unit,
// For top action menu
onShareClicked: (() -> Unit)?,
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onMetadataViewerClicked: () -> Unit,
onEditInfoClicked: () -> Unit,
onRecommendClicked: () -> Unit,
onMergedSettingsClicked: () -> Unit,
onMergeClicked: () -> Unit,
onMergeWithAnotherClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
onRefresh = onRefresh,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
clipIndicatorToPadding = true,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val chapterListState = rememberLazyListState()
val chapters = remember(state) { state.processedChapters.toList() }
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier.padding(insetPadding),
topBar = {
MangaSmallAppBar(
modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) },
title = state.manga.title,
titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f },
backgroundAlphaProvider = { 1f },
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
onBackClicked = internalOnBackPressed,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
showEditInfo = state.manga.favorite,
onEditInfoClicked = onEditInfoClicked,
showRecommends = state.showRecommendationsInOverflow,
onRecommendClicked = onRecommendClicked,
showMergeSettings = state.manga.source == MERGED_SOURCE_ID,
onMergedSettingsClicked = onMergedSettingsClicked,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(chapters)
},
onInvertSelection = {
val toSelect = chapters - selected
selected.clear()
selected.addAll(toSelect)
},
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(0.5f),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.all { it.chapter.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
selected.clear()
}.takeIf { selected.any { !it.chapter.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
selected.clear()
}.takeIf { selected.any { it.chapter.read } },
onMarkPreviousAsReadClicked = {
onMarkPreviousAsReadClicked(selected[0].chapter)
selected.clear()
}.takeIf { selected.size == 1 },
onDownloadClicked = {
onDownloadChapter!!(selected, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.chapter })
selected.clear()
}.takeIf {
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
},
)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (chapters.any { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
}
Text(text = stringResource(id = id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
)
}
},
) { contentPadding ->
Row {
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
MangaInfoHeader(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(bottom = withNavBarContentPadding.calculateBottomPadding()),
windowWidthSizeClass = WindowWidthSizeClass.Expanded,
appBarPadding = contentPadding.calculateTopPadding(),
title = state.manga.title,
author = state.manga.author,
artist = state.manga.artist,
description = state.manga.description,
tagsProvider = { state.manga.genre },
sourceName = remember { state.source.getNameForMangaInfo() },
isStubSource = remember { state.source is SourceManager.StubSource },
coverDataProvider = { state.manga },
favorite = state.manga.favorite,
status = state.manga.status,
trackingCount = state.trackingCount,
fromSource = state.isFromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onMergeClicked = onMergeClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClicked,
doSearch = onSearch,
showRecommendsInOverflow = state.showRecommendationsInOverflow,
showMergeWithAnother = state.showMergeWithAnother,
onRecommendClicked = onRecommendClicked,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
mangaMetadataHeader = remember {
getDescriptionComposable(
source = state.source.getMainSource<MetadataSource<*, *>>(),
state = state,
openMetadataViewer = onMetadataViewerClicked,
search = { onSearch(it, false) },
)
},
searchMetadataChips = remember { SearchMetadataChips(state.meta, state.source, state.manga.genre) },
)
val chaptersWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f
VerticalFastScroller(
listState = chapterListState,
modifier = Modifier.weight(chaptersWeight),
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = withNavBarContentPadding,
) {
item(contentType = "header") {
ChapterHeader(
chapterCount = chapters.size,
isChapterFiltered = state.manga.chaptersFiltered(),
onFilterButtonClicked = onFilterButtonClicked,
)
}
items(items = chapters) { chapterItem ->
val (chapter, downloadState, downloadProgress) = chapterItem
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
} else {
chapter.name
}
}
val date = remember(chapter.dateUpload) {
chapter.dateUpload
.takeIf { it > 0 }
?.let {
Date(it).toRelativeString(
context,
state.dateRelativeTime,
state.dateFormat,
)
}
}
val lastPageRead = remember(chapter.lastPageRead) {
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
}
val scanlator =
remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
MangaChapterListItem(
title = chapterTitle,
date = date,
readProgress = lastPageRead?.let {
stringResource(
id = R.string.chapter_progress,
it + 1,
)
},
scanlator = scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
selected = selected.contains(chapterItem),
downloadState = downloadState,
downloadProgress = downloadProgress,
onLongClick = {
val dispatched = onChapterItemLongClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onChapterItemClick(
chapterItem = chapterItem,
selected = selected,
chapters = chapters,
selectedPositions = selectedPositions,
onChapterClicked = onChapterClicked,
)
},
onDownloadClick = if (onDownloadChapter != null) {
{ onDownloadChapter(listOf(chapterItem), it) }
} else null,
)
}
}
}
}
}
}
}
private fun onChapterItemLongClick(
chapterItem: ChapterItem,
selected: MutableList<ChapterItem>,
chapters: List<ChapterItem>,
selectedPositions: Array<Int>,
): Boolean {
if (!selected.contains(chapterItem)) {
val selectedIndex = chapters.indexOf(chapterItem)
if (selected.isEmpty()) {
selected.add(chapterItem)
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
return true
}
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = selectedIndex..selectedIndex
}
range.forEach {
val toAdd = chapters[it]
if (!selected.contains(toAdd)) {
selected.add(toAdd)
}
}
return true
}
return false
}
fun onChapterItemClick(
chapterItem: ChapterItem,
selected: MutableList<ChapterItem>,
chapters: List<ChapterItem>,
selectedPositions: Array<Int>,
onChapterClicked: (Chapter) -> Unit,
) {
val selectedIndex = chapters.indexOf(chapterItem)
when {
selected.contains(chapterItem) -> {
val removedIndex = chapters.indexOf(chapterItem)
selected.remove(chapterItem)
if (removedIndex == selectedPositions[0]) {
selectedPositions[0] = chapters.indexOfFirst { selected.contains(it) }
} else if (removedIndex == selectedPositions[1]) {
selectedPositions[1] = chapters.indexOfLast { selected.contains(it) }
}
}
selected.isNotEmpty() -> {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
selected.add(chapterItem)
}
else -> onChapterClicked(chapterItem.chapter)
}
}
private fun getDescriptionComposable(
source: MetadataSource<*, *>?,
state: MangaScreenState.Success,
openMetadataViewer: () -> Unit,
search: (String) -> Unit,
): (@Composable () -> Unit)? {
return if (source != null) {
@Composable {
source.DescriptionComposable(state = state, openMetadataViewer = openMetadataViewer, search = search)
}
} else null
}

View File

@ -1,8 +1,12 @@
package eu.kanade.presentation.manga
enum class EditCoverAction {
EDIT,
DELETE,
enum class DownloadAction {
NEXT_1_CHAPTER,
NEXT_5_CHAPTERS,
NEXT_10_CHAPTERS,
CUSTOM,
UNREAD_CHAPTERS,
ALL_CHAPTERS
}
enum class ChapterDownloadAction {
@ -11,3 +15,8 @@ enum class ChapterDownloadAction {
CANCEL,
DELETE,
}
enum class EditCoverAction {
EDIT,
DELETE,
}

View File

@ -0,0 +1,61 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
@Composable
fun ChapterHeader(
chapterCount: Int?,
isChapterFiltered: Boolean,
onFilterButtonClicked: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (chapterCount == null) {
stringResource(id = R.string.chapters)
} else {
quantityStringResource(id = R.plurals.manga_num_chapters, quantity = chapterCount)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onBackground,
)
CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
IconButton(onClick = onFilterButtonClicked) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = stringResource(id = R.string.action_filter),
tint = if (isChapterFiltered) {
Color(LocalContext.current.getResourceColor(R.attr.colorFilterActive))
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}
}
}

View File

@ -0,0 +1,9 @@
package eu.kanade.presentation.manga.components
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun DotSeparatorText() {
Text(text = "")
}

View File

@ -0,0 +1,197 @@
package eu.kanade.presentation.manga.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
fun MangaBottomActionMenu(
visible: Boolean,
modifier: Modifier = Modifier,
onBookmarkClicked: (() -> Unit)?,
onRemoveBookmarkClicked: (() -> Unit)?,
onMarkAsReadClicked: (() -> Unit)?,
onMarkAsUnreadClicked: (() -> Unit)?,
onMarkPreviousAsReadClicked: (() -> Unit)?,
onDownloadClicked: (() -> Unit)?,
onDeleteClicked: (() -> Unit)?,
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(expandFrom = Alignment.Bottom),
exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
) {
val scope = rememberCoroutineScope()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
tonalElevation = 3.dp,
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0 until 7).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1000)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
if (onBookmarkClicked != null) {
Button(
title = stringResource(id = R.string.action_bookmark),
icon = Icons.Default.BookmarkAdd,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = onBookmarkClicked,
)
}
if (onRemoveBookmarkClicked != null) {
Button(
title = stringResource(id = R.string.action_remove_bookmark),
icon = Icons.Default.BookmarkRemove,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = onRemoveBookmarkClicked,
)
}
if (onMarkAsReadClicked != null) {
Button(
title = stringResource(id = R.string.action_mark_as_read),
icon = Icons.Default.DoneAll,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = onMarkAsReadClicked,
)
}
if (onMarkAsUnreadClicked != null) {
Button(
title = stringResource(id = R.string.action_mark_as_unread),
icon = Icons.Default.RemoveDone,
toConfirm = confirm[3],
onLongClick = { onLongClickItem(3) },
onClick = onMarkAsUnreadClicked,
)
}
if (onMarkPreviousAsReadClicked != null) {
Button(
title = stringResource(id = R.string.action_mark_previous_as_read),
icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp),
toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) },
onClick = onMarkPreviousAsReadClicked,
)
}
if (onDownloadClicked != null) {
Button(
title = stringResource(id = R.string.action_download),
icon = Icons.Default.Download,
toConfirm = confirm[5],
onLongClick = { onLongClickItem(5) },
onClick = onDownloadClicked,
)
}
if (onDeleteClicked != null) {
Button(
title = stringResource(id = R.string.action_delete),
icon = Icons.Default.Delete,
toConfirm = confirm[6],
onLongClick = { onLongClickItem(6) },
onClick = onDeleteClicked,
)
}
}
}
}
}
@Composable
private fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f)
Column(
modifier = Modifier
.size(48.dp)
.weight(animatedWeight)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onLongClick = onLongClick,
onClick = onClick,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = title,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
Text(
text = title,
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
)
}
}
}

View File

@ -0,0 +1,139 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
@Composable
fun MangaChapterListItem(
modifier: Modifier = Modifier,
title: String,
date: String?,
readProgress: String?,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
selected: Boolean,
downloadState: Download.State,
downloadProgress: Int,
onLongClick: () -> Unit,
onClick: () -> Unit,
onDownloadClick: ((ChapterDownloadAction) -> Unit)?,
) {
Row(
modifier = modifier
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
) {
Column(
modifier = Modifier
.weight(1f)
.alpha(if (read) ReadItemAlpha else 1f),
) {
val textColor = if (bookmark) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) }
if (bookmark) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = stringResource(id = R.string.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = textColor,
)
Spacer(modifier = Modifier.width(2.dp))
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
.copy(color = textColor),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
)
}
Spacer(modifier = Modifier.height(6.dp))
Row {
ProvideTextStyle(
value = MaterialTheme.typography.bodyMedium
.copy(color = textColor, fontSize = 12.sp),
) {
if (date != null) {
Text(
text = date,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (readProgress != null || scanlator != null) DotSeparatorText()
}
if (readProgress != null) {
Text(
text = readProgress,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(ReadItemAlpha),
)
if (scanlator != null) DotSeparatorText()
}
if (scanlator != null) {
Text(
text = scanlator,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
// Download view
if (onDownloadClick != null) {
ChapterDownloadIndicator(
modifier = Modifier.padding(start = 4.dp),
downloadState = downloadState,
downloadProgress = downloadProgress,
onClick = onDownloadClick,
)
}
}
}
private const val ReadItemAlpha = .38f

View File

@ -0,0 +1,45 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.Button
import eu.kanade.tachiyomi.R
@Composable
fun MangaInfoButtons(
showRecommendsButton: Boolean,
showMergeWithAnotherButton: Boolean,
onRecommendClicked: () -> Unit,
onMergeWithAnotherClicked: () -> Unit,
) {
if (showRecommendsButton || showMergeWithAnotherButton) {
Column(Modifier.fillMaxWidth()) {
if (showMergeWithAnotherButton) {
Button(
onClick = onMergeWithAnotherClicked,
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text(stringResource(R.string.merge_with_another_source))
}
}
if (showRecommendsButton) {
Button(
onClick = onRecommendClicked,
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text(stringResource(R.string.az_recommends))
}
}
}
}
}

View File

@ -0,0 +1,630 @@
package eu.kanade.presentation.manga.components
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.CallMerge
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.clickableNoIndication
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlin.math.roundToInt
@Composable
fun MangaInfoHeader(
modifier: Modifier = Modifier,
windowWidthSizeClass: WindowWidthSizeClass,
appBarPadding: Dp,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
sourceName: String,
isStubSource: Boolean,
coverDataProvider: () -> Manga,
favorite: Boolean,
status: Long,
trackingCount: Int,
fromSource: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onMergeClicked: () -> Unit,
onTagClicked: (String) -> Unit,
onEditCategory: (() -> Unit)?,
onCoverClick: () -> Unit,
doSearch: (query: String, global: Boolean) -> Unit,
onRecommendClicked: () -> Unit,
showRecommendsInOverflow: Boolean,
showMergeWithAnother: Boolean,
onMergeWithAnotherClicked: () -> Unit,
mangaMetadataHeader: (@Composable () -> Unit)?,
searchMetadataChips: SearchMetadataChips?,
) {
val context = LocalContext.current
Column(modifier = modifier) {
Box {
// Backdrop
val backdropGradientColors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
AsyncImage(
model = coverDataProvider(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(colors = backdropGradientColors),
)
}
.alpha(.2f),
)
// Manga & source info
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
MangaAndSourceTitlesSmall(
appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
doSearch = doSearch,
author = author,
artist = artist,
status = status,
sourceName = sourceName,
isStubSource = isStubSource,
)
} else {
MangaAndSourceTitlesLarge(
appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
doSearch = doSearch,
author = author,
artist = artist,
status = status,
sourceName = sourceName,
isStubSource = isStubSource,
)
}
}
// SY --> Manga metadata
mangaMetadataHeader?.invoke()
// SY <--
// Action buttons
Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
MangaActionButton(
title = if (favorite) {
stringResource(id = R.string.in_library)
} else {
stringResource(id = R.string.add_to_library)
},
icon = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onAddToLibraryClicked,
onLongClick = onEditCategory,
)
if (onTrackingClicked != null) {
MangaActionButton(
title = if (trackingCount == 0) {
stringResource(id = R.string.manga_tracking_tab)
} else {
quantityStringResource(id = R.plurals.num_trackers, quantity = trackingCount, trackingCount)
},
icon = if (trackingCount == 0) Icons.Default.Sync else Icons.Default.Done,
color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary,
onClick = onTrackingClicked,
)
}
if (onWebViewClicked != null) {
MangaActionButton(
title = stringResource(id = R.string.action_web_view),
icon = Icons.Default.Public,
color = defaultActionButtonColor,
onClick = onWebViewClicked,
)
}
MangaActionButton(
title = stringResource(R.string.merge),
icon = Icons.Outlined.CallMerge,
color = defaultActionButtonColor,
onClick = onMergeClicked,
)
}
// Expandable description-tags
Column {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact)
}
if (!description.isNullOrBlank()) {
val trimmedDescription = remember(description) {
description
.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
}
MangaSummary(
expandedDescription = description,
shrunkDescription = trimmedDescription,
expanded = expanded,
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(description, description) },
onClick = { onExpanded(!expanded) },
),
)
}
val tags = tagsProvider()
if (!tags.isNullOrEmpty()) {
Box(
modifier = Modifier
.padding(top = 8.dp)
.padding(vertical = 12.dp)
.animateContentSize(),
) {
if (expanded) {
if (searchMetadataChips != null) {
NamespaceTags(
tags = searchMetadataChips,
onClick = onTagClicked,
onLongClick = { doSearch(it, true) },
)
} else {
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
mainAxisSpacing = 4.dp,
crossAxisSpacing = 8.dp,
) {
tags.forEach {
TagsChip(
text = it,
onClick = { onTagClicked(it) },
onLongClick = { doSearch(it, true) },
)
}
}
}
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items = tags) {
TagsChip(
text = it,
onClick = { onTagClicked(it) },
onLongClick = { doSearch(it, true) },
)
}
}
}
}
}
}
// SY -->
MangaInfoButtons(
showRecommendsButton = !showRecommendsInOverflow,
showMergeWithAnotherButton = showMergeWithAnother,
onRecommendClicked = onRecommendClicked,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
)
// SY <--
}
}
@Composable
private fun MangaAndSourceTitlesLarge(
appBarPadding: Dp,
coverDataProvider: () -> Manga,
onCoverClick: () -> Unit,
title: String,
context: Context,
doSearch: (query: String, global: Boolean) -> Unit,
author: String?,
artist: String?,
status: Long,
sourceName: String,
isStubSource: Boolean,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(0.4f),
data = coverDataProvider(),
onClick = onCoverClick,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
onClick = { if (title.isNotBlank()) doSearch(title, true) },
),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
author,
author,
)
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
textAlign = TextAlign.Center,
)
if (!artist.isNullOrBlank()) {
Text(
text = artist,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(artist, artist) },
onClick = { doSearch(artist, true) },
),
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.secondaryItemAlpha(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (status) {
SManga.ONGOING.toLong() -> Icons.Default.Schedule
SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SManga.CANCELLED.toLong() -> Icons.Default.Close
SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
},
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
Text(
text = when (status) {
SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
else -> stringResource(id = R.string.unknown)
},
)
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Text(
text = sourceName,
modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
)
}
}
}
}
@Composable
private fun MangaAndSourceTitlesSmall(
appBarPadding: Dp,
coverDataProvider: () -> Manga,
onCoverClick: () -> Unit,
title: String,
context: Context,
doSearch: (query: String, global: Boolean) -> Unit,
author: String?,
artist: String?,
status: Long,
sourceName: String,
isStubSource: Boolean,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Book(
modifier = Modifier.sizeIn(maxWidth = 100.dp),
data = coverDataProvider(),
onClick = onCoverClick,
)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
text = title.ifBlank { stringResource(id = R.string.unknown) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
onClick = { if (title.isNotBlank()) doSearch(title, true) },
),
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = author?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.unknown_author),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
author,
author,
)
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
)
if (!artist.isNullOrBlank()) {
Text(
text = artist,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(artist, artist) },
onClick = { doSearch(artist, true) },
),
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.secondaryItemAlpha(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (status) {
SManga.ONGOING.toLong() -> Icons.Default.Schedule
SManga.COMPLETED.toLong() -> Icons.Default.DoneAll
SManga.LICENSED.toLong() -> Icons.Default.AttachMoney
SManga.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SManga.CANCELLED.toLong() -> Icons.Default.Close
SManga.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
},
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
Text(
text = when (status) {
SManga.ONGOING.toLong() -> stringResource(id = R.string.ongoing)
SManga.COMPLETED.toLong() -> stringResource(id = R.string.completed)
SManga.LICENSED.toLong() -> stringResource(id = R.string.licensed)
SManga.PUBLISHING_FINISHED.toLong() -> stringResource(id = R.string.publishing_finished)
SManga.CANCELLED.toLong() -> stringResource(id = R.string.cancelled)
SManga.ON_HIATUS.toLong() -> stringResource(id = R.string.on_hiatus)
else -> stringResource(id = R.string.unknown)
},
)
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Text(
text = sourceName,
modifier = Modifier.clickableNoIndication { doSearch(sourceName, false) },
)
}
}
}
}
}
@Composable
private fun MangaSummary(
expandedDescription: String,
shrunkDescription: String,
expanded: Boolean,
modifier: Modifier = Modifier,
) {
var expandedHeight by remember { mutableStateOf(0) }
var shrunkHeight by remember { mutableStateOf(0) }
val heightDelta = remember(expandedHeight, shrunkHeight) { expandedHeight - shrunkHeight }
val animProgress by animateFloatAsState(if (expanded) 1f else 0f)
val scrimHeight = with(LocalDensity.current) { remember { 24.sp.roundToPx() } }
SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints ->
val shrunkPlaceable = subcompose("description-s") {
Text(
text = "\n\n", // Shows at least 3 lines
style = MaterialTheme.typography.bodyMedium,
)
}.map { it.measure(constraints) }
shrunkHeight = shrunkPlaceable.maxByOrNull { it.height }?.height ?: 0
val expandedPlaceable = subcompose("description-l") {
Text(
text = expandedDescription,
style = MaterialTheme.typography.bodyMedium,
)
}.map { it.measure(constraints) }
expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0
val actualPlaceable = subcompose("description") {
Text(
text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(),
)
}.map { it.measure(constraints) }
val scrimPlaceable = subcompose("scrim") {
val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
Box(
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
contentAlignment = Alignment.Center,
) {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
)
}
}.map { it.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight)) }
val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt()
layout(constraints.maxWidth, currentHeight) {
actualPlaceable.forEach {
it.place(0, 0)
}
val scrimY = currentHeight - scrimHeight
scrimPlaceable.forEach {
it.place(0, scrimY)
}
}
}
}
@Composable
private fun RowScope.MangaActionButton(
title: String,
icon: ImageVector,
color: Color,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
TextButton(
onClick = onClick,
modifier = Modifier.weight(1f),
onLongClick = onLongClick,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = title,
color = color,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
}
}

View File

@ -0,0 +1,274 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FlipToBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R
@Composable
fun MangaSmallAppBar(
modifier: Modifier = Modifier,
title: String,
titleAlphaProvider: () -> Float,
backgroundAlphaProvider: () -> Float = titleAlphaProvider,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onBackClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
showEditInfo: Boolean,
onEditInfoClicked: () -> Unit,
showRecommends: Boolean,
onRecommendClicked: () -> Unit,
showMergeSettings: Boolean,
onMergedSettingsClicked: () -> Unit,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
) {
val isActionMode = actionModeCounter > 0
val backgroundAlpha = if (isActionMode) 1f else backgroundAlphaProvider()
val backgroundColor by TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f)
Column(
modifier = modifier.drawBehind {
drawRect(backgroundColor.copy(alpha = backgroundAlpha))
},
) {
SmallTopAppBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
title = {
Text(
text = if (isActionMode) actionModeCounter.toString() else title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(titleAlphaProvider()),
)
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = if (isActionMode) Icons.Default.Close else Icons.Default.ArrowBack,
contentDescription = stringResource(id = R.string.abc_action_bar_up_description),
)
}
},
actions = {
if (isActionMode) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.action_select_all),
)
}
IconButton(onClick = onInvertSelection) {
Icon(
imageVector = Icons.Default.FlipToBack,
contentDescription = stringResource(id = R.string.action_select_inverse),
)
}
} else {
if (onShareClicked != null) {
IconButton(onClick = onShareClicked) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(id = R.string.action_share),
)
}
}
if (onDownloadClicked != null) {
val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(id = R.string.manga_download),
)
}
val onDismissRequest = { onDownloadExpanded(false) }
DropdownMenu(
expanded = downloadExpanded,
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_1)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_1_CHAPTER)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_5)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_5_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_10)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_10_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_custom)) },
onClick = {
onDownloadClicked(DownloadAction.CUSTOM)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_unread)) },
onClick = {
onDownloadClicked(DownloadAction.UNREAD_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_all)) },
onClick = {
onDownloadClicked(DownloadAction.ALL_CHAPTERS)
onDismissRequest()
},
)
}
}
}
if (onEditCategoryClicked != null || onMigrateClicked != null || showEditInfo || showRecommends || showMergeSettings) {
val (moreExpanded, onMoreExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onMoreExpanded(!moreExpanded) }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(id = R.string.abc_action_menu_overflow_description),
)
}
val onDismissRequest = { onMoreExpanded(false) }
DropdownMenu(
expanded = moreExpanded,
onDismissRequest = onDismissRequest,
) {
if (onEditCategoryClicked != null) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_edit_categories)) },
onClick = {
onEditCategoryClicked()
onDismissRequest()
},
)
}
if (onMigrateClicked != null) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_migrate)) },
onClick = {
onMigrateClicked()
onDismissRequest()
},
)
}
if (showEditInfo) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit_info)) },
onClick = {
onEditInfoClicked()
onDismissRequest()
},
)
}
if (showRecommends) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.az_recommends)) },
onClick = {
onRecommendClicked()
onDismissRequest()
},
)
}
if (showMergeSettings) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.merge_settings)) },
onClick = {
onMergedSettingsClicked()
onDismissRequest()
},
)
}
}
}
}
}
},
// Background handled by parent
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
)
if (downloadedOnlyMode) {
Text(
text = stringResource(id = R.string.label_downloaded_only),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.tertiary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
if (incognitoMode) {
Text(
text = stringResource(id = R.string.pref_incognito_mode),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
}
}

View File

@ -0,0 +1,167 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.manga.DownloadAction
import kotlin.math.roundToInt
@Composable
fun MangaTopAppBar(
modifier: Modifier = Modifier,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
coverDataProvider: () -> Manga,
sourceName: String,
isStubSource: Boolean,
favorite: Boolean,
status: Long,
trackingCount: Int,
chapterCount: Int?,
chapterFiltered: Boolean,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
fromSource: Boolean,
onBackClicked: () -> Unit,
onCoverClick: () -> Unit,
onTagClicked: (String) -> Unit,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onFilterButtonClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onEditInfoClicked: () -> Unit,
onRecommendClicked: () -> Unit,
showRecommendsInOverflow: Boolean,
showMergedSettings: Boolean,
onMergedSettingsClicked: () -> Unit,
onMergeClicked: () -> Unit,
showMergeWithAnother: Boolean,
onMergeWithAnotherClicked: () -> Unit,
doGlobalSearch: (query: String, global: Boolean) -> Unit,
mangaMetadataHeader: (@Composable () -> Unit)?,
searchMetadataChips: SearchMetadataChips?,
scrollBehavior: TopAppBarScrollBehavior?,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onSmallAppBarHeightChanged: (Int) -> Unit,
) {
val scrollPercentageProvider = { scrollBehavior?.scrollFraction?.coerceIn(0f, 1f) ?: 0f }
val inverseScrollPercentageProvider = { 1f - scrollPercentageProvider() }
Layout(
modifier = modifier,
content = {
val (smallHeightPx, onSmallHeightPxChanged) = remember { mutableStateOf(0) }
Column(modifier = Modifier.layoutId("mangaInfo")) {
MangaInfoHeader(
windowWidthSizeClass = WindowWidthSizeClass.Compact,
appBarPadding = with(LocalDensity.current) { smallHeightPx.toDp() },
title = title,
author = author,
artist = artist,
description = description,
tagsProvider = tagsProvider,
sourceName = sourceName,
isStubSource = isStubSource,
coverDataProvider = coverDataProvider,
favorite = favorite,
status = status,
trackingCount = trackingCount,
fromSource = fromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onMergeClicked = onMergeClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClick,
doSearch = doGlobalSearch,
showRecommendsInOverflow = showRecommendsInOverflow,
showMergeWithAnother = showMergeWithAnother,
onRecommendClicked = onRecommendClicked,
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
mangaMetadataHeader = mangaMetadataHeader,
searchMetadataChips = searchMetadataChips,
)
ChapterHeader(
chapterCount = chapterCount,
isChapterFiltered = chapterFiltered,
onFilterButtonClicked = onFilterButtonClicked,
)
}
MangaSmallAppBar(
modifier = Modifier
.layoutId("topBar")
.onSizeChanged {
onSmallHeightPxChanged(it.height)
onSmallAppBarHeightChanged(it.height)
},
title = title,
titleAlphaProvider = { if (actionModeCounter == 0) scrollPercentageProvider() else 1f },
incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode,
onBackClicked = onBackClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
// SY -->
showEditInfo = favorite,
onEditInfoClicked = onEditInfoClicked,
showRecommends = showRecommendsInOverflow,
onRecommendClicked = onRecommendClicked,
showMergeSettings = showMergedSettings,
onMergedSettingsClicked = onMergedSettingsClicked,
// SY <--
actionModeCounter = actionModeCounter,
onSelectAll = onSelectAll,
onInvertSelection = onInvertSelection,
)
},
) { measurables, constraints ->
val mangaInfoPlaceable = measurables
.first { it.layoutId == "mangaInfo" }
.measure(constraints.copy(maxHeight = Constraints.Infinity))
val topBarPlaceable = measurables
.first { it.layoutId == "topBar" }
.measure(constraints)
val mangaInfoHeight = mangaInfoPlaceable.height
val topBarHeight = topBarPlaceable.height
val mangaInfoSansTopBarHeightPx = mangaInfoHeight - topBarHeight
val layoutHeight = topBarHeight +
(mangaInfoSansTopBarHeightPx * inverseScrollPercentageProvider()).roundToInt()
layout(constraints.maxWidth, layoutHeight) {
val mangaInfoY = (-mangaInfoSansTopBarHeightPx * scrollPercentageProvider()).roundToInt()
mangaInfoPlaceable.place(0, mangaInfoY)
topBarPlaceable.place(0, 0)
// Update offset limit
val offsetLimit = -mangaInfoSansTopBarHeightPx.toFloat()
if (scrollBehavior?.state?.offsetLimit != offsetLimit) {
scrollBehavior?.state?.offsetLimit = offsetLimit
}
}
}
}

View File

@ -0,0 +1,216 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ChipBorder
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import eu.kanade.presentation.components.SuggestionChip
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.source.online.all.EHentai
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.EH_SOURCE_ID
import exh.source.EXH_SOURCE_ID
import exh.util.SourceTagsUtil
import tachiyomi.source.Source
@Immutable
data class DisplayTag(
val namespace: String?,
val text: String,
val search: String,
val border: Dp?,
)
@Immutable
@JvmInline
value class SearchMetadataChips(
val tags: Map<String, List<DisplayTag>>,
) {
companion object {
operator fun invoke(meta: RaisedSearchMetadata?, source: Source, tags: List<String>?): SearchMetadataChips? {
return if (meta != null) {
SearchMetadataChips(
meta.tags
.filterNot { it.type == RaisedSearchMetadata.TAG_TYPE_VIRTUAL }
.map {
DisplayTag(
namespace = it.namespace,
text = it.name,
search = if (!it.namespace.isNullOrEmpty()) {
SourceTagsUtil.getWrappedTag(source.id, namespace = it.namespace, tag = it.name)
} else {
SourceTagsUtil.getWrappedTag(source.id, fullTag = it.name)
} ?: it.name,
border = if (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) {
when (it.type) {
EHentaiSearchMetadata.TAG_TYPE_NORMAL -> 3.dp
EHentaiSearchMetadata.TAG_TYPE_LIGHT -> 1.dp
else -> null
}
} else null,
)
}
.groupBy { it.namespace.orEmpty() },
)
} else if (tags != null && tags.all { it.contains(':') }) {
SearchMetadataChips(
tags
.map { tag ->
val index = tag.indexOf(':')
DisplayTag(tag.substring(0, index).trim(), tag.substring(index + 1).trim(), tag, null)
}
.groupBy {
it.namespace.orEmpty()
},
)
} else null
}
}
}
@Composable
fun NamespaceTags(
tags: SearchMetadataChips,
onClick: (item: String) -> Unit,
onLongClick: (item: String) -> Unit,
) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
tags.tags.forEach { (namespace, tags) ->
Row(Modifier.padding(start = 16.dp)) {
if (namespace.isNotEmpty()) {
TagsChip(namespace, onClick = null, onLongClick = null)
}
FlowRow(
modifier = Modifier.padding(start = 8.dp, end = 16.dp),
mainAxisSpacing = 4.dp,
crossAxisSpacing = 8.dp,
) {
tags.forEach { (_, text, search, border) ->
TagsChip(
text = text,
onClick = { onClick(search) },
onLongClick = { onLongClick(search) },
border = border?.let { SuggestionChipDefaults.suggestionChipBorder(borderWidth = it) },
)
}
}
}
}
}
}
@Composable
fun TagsChip(
text: String,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
border: ChipBorder? = null,
) {
CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
if (onClick != null) {
if (onLongClick != null) {
SuggestionChip(
onClick = onClick,
onLongClick = onLongClick,
label = { Text(text = text, style = MaterialTheme.typography.bodySmall) },
border = border,
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
} else {
SuggestionChip(
onClick = onClick,
label = { Text(text = text, style = MaterialTheme.typography.bodySmall) },
border = border,
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
}
} else {
SuggestionChip(
label = { Text(text = text, style = MaterialTheme.typography.bodySmall) },
border = border,
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
@Preview
@Composable
fun NamespaceTagsPreview() {
TachiyomiTheme {
Surface {
val context = LocalContext.current
NamespaceTags(
tags = remember {
EHentaiSearchMetadata().apply {
this.tags.addAll(
arrayOf(
RaisedTag(
"Male",
"Test",
EHentaiSearchMetadata.TAG_TYPE_NORMAL,
),
RaisedTag(
"Male",
"Test2",
EHentaiSearchMetadata.TAG_TYPE_WEAK,
),
RaisedTag(
"Male",
"Test3",
EHentaiSearchMetadata.TAG_TYPE_LIGHT,
),
RaisedTag(
"Female",
"Test",
EHentaiSearchMetadata.TAG_TYPE_NORMAL,
),
RaisedTag(
"Female",
"Test2",
EHentaiSearchMetadata.TAG_TYPE_WEAK,
),
RaisedTag(
"Female",
"Test3",
EHentaiSearchMetadata.TAG_TYPE_LIGHT,
),
),
)
}.let { SearchMetadataChips(it, EHentai(EXH_SOURCE_ID, true, context), emptyList()) }!!
},
onClick = {},
onLongClick = {},
)
}
}
}

View File

@ -1,5 +1,29 @@
package eu.kanade.presentation.util
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
@Composable
fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
return remember {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}

View File

@ -0,0 +1,158 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.kanade.presentation.util
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarScrollState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlin.math.abs
/**
* A [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and height of a top
* app bar.
*
* A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when
* the nested content is pulled up, and will expand back the collapsed area when the content is
* pulled all the way down.
*
* @param decayAnimationSpec a [DecayAnimationSpec] that will be used by the top app bar motion
* when the user flings the content. Preferably, this should match the animation spec used by the
* scrollable content. See also [androidx.compose.animation.rememberSplineBasedDecay] for a
* default [DecayAnimationSpec] that can be used with this behavior.
* @param canScroll a callback used to determine whether scroll events are to be
* handled by this [ExitUntilCollapsedScrollBehavior]
*/
class ExitUntilCollapsedScrollBehavior(
override val state: TopAppBarScrollState,
val decayAnimationSpec: DecayAnimationSpec<Float>,
val canScroll: () -> Boolean = { true },
) : TopAppBarScrollBehavior {
override val scrollFraction: Float
get() = if (state.offsetLimit != 0f) state.offset / state.offsetLimit else 0f
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Don't intercept if scrolling down.
if (!canScroll() || available.y > 0f) return Offset.Zero
val newOffset = (state.offset + available.y)
val coerced =
newOffset.coerceIn(minimumValue = state.offsetLimit, maximumValue = 0f)
return if (newOffset == coerced) {
// Nothing coerced, meaning we're in the middle of top app bar collapse or
// expand.
state.offset = coerced
// Consume only the scroll on the Y axis.
available.copy(x = 0f)
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
state.contentOffset += consumed.y
if (available.y < 0f || consumed.y < 0f) {
// When scrolling up, just update the state's offset.
val oldOffset = state.offset
state.offset = (state.offset + consumed.y).coerceIn(
minimumValue = state.offsetLimit,
maximumValue = 0f,
)
return Offset(0f, state.offset - oldOffset)
}
if (consumed.y == 0f && available.y > 0) {
// Reset the total offset to zero when scrolling all the way down. This will
// eliminate some float precision inaccuracies.
state.contentOffset = 0f
}
if (available.y > 0f) {
// Adjust the offset in case the consumed delta Y is less than what was recorded
// as available delta Y in the pre-scroll.
val oldOffset = state.offset
state.offset = (state.offset + available.y).coerceIn(
minimumValue = state.offsetLimit,
maximumValue = 0f,
)
return Offset(0f, state.offset - oldOffset)
}
return Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val result = super.onPostFling(consumed, available)
if ((available.y < 0f && state.contentOffset == 0f) ||
(available.y > 0f && state.offset < 0f)
) {
return result +
onTopBarFling(
scrollBehavior = this@ExitUntilCollapsedScrollBehavior,
initialVelocity = available.y,
decayAnimationSpec = decayAnimationSpec,
)
}
return result
}
}
}
/**
* Tachiyomi: Remove snap behavior
*/
private suspend fun onTopBarFling(
scrollBehavior: TopAppBarScrollBehavior,
initialVelocity: Float,
decayAnimationSpec: DecayAnimationSpec<Float>,
): Velocity {
if (abs(initialVelocity) > 1f) {
var remainingVelocity = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
)
.animateDecay(decayAnimationSpec) {
val delta = value - lastValue
val initialOffset = scrollBehavior.state.offset
scrollBehavior.state.offset =
(initialOffset + delta).coerceIn(
minimumValue = scrollBehavior.state.offsetLimit,
maximumValue = 0f,
)
val consumed = abs(initialOffset - scrollBehavior.state.offset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
return Velocity(0f, remainingVelocity)
}
return Velocity.Zero
}

View File

@ -0,0 +1,24 @@
package eu.kanade.presentation.util
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@ReadOnlyComposable
fun calculateWindowWidthSizeClass(): WindowWidthSizeClass {
val configuration = LocalConfiguration.current
return fromWidth(configuration.smallestScreenWidthDp.dp)
}
private fun fromWidth(width: Dp): WindowWidthSizeClass {
require(width >= 0.dp) { "Width must not be negative" }
return when {
width < 720.dp -> WindowWidthSizeClass.Compact // Was 600
width < 840.dp -> WindowWidthSizeClass.Medium
else -> WindowWidthSizeClass.Expanded
}
}

View File

@ -111,6 +111,7 @@ abstract class AbstractBackupManager(protected val context: Context) {
coverLastModified = manga.cover_last_modified,
dateAdded = manga.date_added,
mangaId = manga.id!!,
filteredScanlators = manga.filtered_scanlators,
)
}
return manga.id!!

View File

@ -97,7 +97,7 @@ data class BackupManga(
artist = customArtist,
description = customDescription,
genre = customGenre,
status = customStatus.takeUnless { it == 0 },
status = customStatus.takeUnless { it == 0 }?.toLong(),
)
}
return null
@ -138,7 +138,7 @@ data class BackupManga(
backupManga.customAuthor = it.author
backupManga.customDescription = it.description
backupManga.customGenre = it.genre
backupManga.customStatus = it.status ?: 0
backupManga.customStatus = it.status?.toInt() ?: 0
}
}
// SY <--

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.source.model.SChapter
import java.io.Serializable
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
interface Chapter : SChapter, Serializable {
@ -29,3 +30,21 @@ interface Chapter : SChapter, Serializable {
}
}
}
fun Chapter.toDomainChapter(): DomainChapter? {
if (id == null || manga_id == null) return null
return DomainChapter(
id = id!!,
mangaId = manga_id!!,
read = read,
bookmark = bookmark,
lastPageRead = last_page_read.toLong(),
dateFetch = date_fetch,
sourceOrder = source_order.toLong(),
url = url,
name = name,
dateUpload = date_upload,
chapterNumber = chapter_number,
scanlator = scanlator,
)
}

View File

@ -16,4 +16,26 @@ class LibraryManga : MangaImpl() {
// SY -->
var read: Int = 0
// SY <--
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LibraryManga) return false
if (!super.equals(other)) return false
if (unreadCount != other.unreadCount) return false
if (readCount != other.readCount) return false
if (category != other.category) return false
if (read != other.read) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + unreadCount
result = 31 * result + readCount
result = 31 * result + category
result = 31 * result + read
return result
}
}

View File

@ -42,7 +42,7 @@ open class MangaImpl : Manga {
set(value) { ogGenre = value }
override var status: Int
get() = if (favorite) customManga?.status ?: ogStatus else ogStatus
get() = if (favorite) customManga?.status?.toInt() ?: ogStatus else ogStatus
set(value) { ogStatus = value }
// SY <--

View File

@ -77,7 +77,7 @@ class CustomMangaManager(val context: Context) {
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null,
val status: Long? = null,
) {
fun toManga() = CustomMangaInfo(
@ -87,7 +87,7 @@ class CustomMangaManager(val context: Context) {
artist = this@MangaJson.artist,
description = this@MangaJson.description,
genre = this@MangaJson.genre,
status = this@MangaJson.status?.takeUnless { it == 0 },
status = this@MangaJson.status?.takeUnless { it == 0L },
)
}
@ -98,7 +98,7 @@ class CustomMangaManager(val context: Context) {
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null,
val status: Long? = null,
) {
val genreString by lazy {
genre?.joinToString()

View File

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

View File

@ -58,6 +58,33 @@ open class Page(
statusCallback = f
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Page) return false
if (index != other.index) return false
if (url != other.url) return false
if (imageUrl != other.imageUrl) return false
if (number != other.number) return false
if (status != other.status) return false
if (progress != other.progress) return false
if (statusSubject != other.statusSubject) return false
if (statusCallback != other.statusCallback) return false
return true
}
override fun hashCode(): Int {
var result = index
result = 31 * result + url.hashCode()
result = 31 * result + (imageUrl?.hashCode() ?: 0)
result = 31 * result + status
result = 31 * result + progress
result = 31 * result + (statusSubject?.hashCode() ?: 0)
result = 31 * result + (statusCallback?.hashCode() ?: 0)
return result
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1

View File

@ -112,10 +112,8 @@ interface MetadataSource<M : RaisedSearchMetadata, I> : CatalogueSource {
}
}
fun getDescriptionAdapter(controller: MangaController): RecyclerView.Adapter<*>?
@Composable
fun DescriptionComposable(controller: MangaController)
fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit)
fun MangaInfo.id() = db.getManga(key, id).executeAsBlocking()?.id
val SManga.id get() = (this as? Manga)?.id

View File

@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext
@ -46,7 +46,6 @@ import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.toGenreString
import exh.metadata.metadata.base.RaisedTag
import exh.ui.login.EhLoginActivity
import exh.ui.metadata.adapters.EHentaiDescription
import exh.ui.metadata.adapters.EHentaiDescriptionAdapter
import exh.util.UriFilter
import exh.util.UriGroup
import exh.util.asObservableWithAsyncStacktrace
@ -84,7 +83,6 @@ import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.util.ArrayList
// TODO Consider gallery updating when doing tabbed browsing
class EHentai(
@ -1071,13 +1069,9 @@ class EHentai(
return "${uri.scheme}://${uri.host}/g/${obj["gid"]!!.jsonPrimitive.int}/${obj["token"]!!.jsonPrimitive.content}/"
}
override fun getDescriptionAdapter(controller: MangaController): EHentaiDescriptionAdapter {
return EHentaiDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
EHentaiDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
EHentaiDescription(state, openMetadataViewer, search)
}
companion object {

View File

@ -11,14 +11,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.HitomiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.HitomiDescription
import exh.ui.metadata.adapters.HitomiDescriptionAdapter
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import tachiyomi.source.model.MangaInfo
@ -138,13 +137,9 @@ class Hitomi(delegate: HttpSource, val context: Context) :
return "https://hitomi.la/manga/${uri.pathSegments[1].substringBefore('.')}.html"
}
override fun getDescriptionAdapter(controller: MangaController): HitomiDescriptionAdapter {
return HitomiDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
HitomiDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
HitomiDescription(state, openMetadataViewer)
}
companion object {

View File

@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.RandomMangaSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.md.MangaDexFabHeaderAdapter
import exh.md.dto.MangaDto
@ -50,7 +50,6 @@ import exh.md.utils.MdUtil
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.MangaDexDescription
import exh.ui.metadata.adapters.MangaDexDescriptionAdapter
import okhttp3.OkHttpClient
import okhttp3.Response
import rx.Observable
@ -217,13 +216,9 @@ class MangaDex(delegate: HttpSource, val context: Context) :
// MetadataSource methods
override val metaClass: KClass<MangaDexSearchMetadata> = MangaDexSearchMetadata::class
override fun getDescriptionAdapter(controller: MangaController): MangaDexDescriptionAdapter {
return MangaDexDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
MangaDexDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
MangaDexDescription(state, openMetadataViewer)
}
override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Triple<MangaDto, List<String>, StatisticsMangaDto>) {

View File

@ -11,13 +11,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.NHentaiDescription
import exh.ui.metadata.adapters.NHentaiDescriptionAdapter
import exh.util.trimOrNull
import exh.util.urlImportFetchSearchManga
import kotlinx.serialization.SerialName
@ -170,13 +169,9 @@ class NHentai(delegate: HttpSource, val context: Context) :
return "$baseUrl/g/${uri.pathSegments[1]}/"
}
override fun getDescriptionAdapter(controller: MangaController): NHentaiDescriptionAdapter {
return NHentaiDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
NHentaiDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
NHentaiDescription(state, openMetadataViewer)
}
companion object {

View File

@ -10,14 +10,13 @@ import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.PervEdenSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.PervEdenDescription
import exh.ui.metadata.adapters.PervEdenDescriptionAdapter
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@ -135,12 +134,8 @@ class PervEden(delegate: HttpSource, val context: Context) :
return newUri.toString()
}
override fun getDescriptionAdapter(controller: MangaController): PervEdenDescriptionAdapter {
return PervEdenDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
PervEdenDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
PervEdenDescription(state, openMetadataViewer)
}
}

View File

@ -11,13 +11,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.EightMusesSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.EightMusesDescription
import exh.ui.metadata.adapters.EightMusesDescriptionAdapter
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@ -99,12 +98,8 @@ class EightMuses(delegate: HttpSource, val context: Context) :
return "/comics/album/${path.joinToString("/")}"
}
override fun getDescriptionAdapter(controller: MangaController): EightMusesDescriptionAdapter {
return EightMusesDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
EightMusesDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
EightMusesDescription(state, openMetadataViewer)
}
}

View File

@ -10,13 +10,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.HBrowseSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.HBrowseDescription
import exh.ui.metadata.adapters.HBrowseDescriptionAdapter
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@ -87,12 +86,8 @@ class HBrowse(delegate: HttpSource, val context: Context) :
return uri.pathSegments.firstOrNull()?.let { "/$it/c00001/" }
}
override fun getDescriptionAdapter(controller: MangaController): HBrowseDescriptionAdapter {
return HBrowseDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
HBrowseDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
HBrowseDescription(state, openMetadataViewer)
}
}

View File

@ -12,14 +12,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.PururinSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.PururinDescription
import exh.ui.metadata.adapters.PururinDescriptionAdapter
import exh.util.dropBlank
import exh.util.trimAll
import exh.util.urlImportFetchSearchManga
@ -117,12 +116,8 @@ class Pururin(delegate: HttpSource, val context: Context) :
return "${PururinSearchMetadata.BASE_URL}/gallery/${uri.pathSegments.getOrNull(1)}/${uri.lastPathSegment}"
}
override fun getDescriptionAdapter(controller: MangaController): PururinDescriptionAdapter {
return PururinDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
PururinDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
PururinDescription(state, openMetadataViewer)
}
}

View File

@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
@ -19,7 +19,6 @@ import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUA
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.TsuminoDescription
import exh.ui.metadata.adapters.TsuminoDescriptionAdapter
import exh.util.dropBlank
import exh.util.trimAll
import exh.util.urlImportFetchSearchManga
@ -144,12 +143,8 @@ class Tsumino(delegate: HttpSource, val context: Context) :
val RATING_FAVORITES_REGEX = "/ ([0-9].*) favs".toRegex()
}
override fun getDescriptionAdapter(controller: MangaController): TsuminoDescriptionAdapter {
return TsuminoDescriptionAdapter(controller)
}
@Composable
override fun DescriptionComposable(controller: MangaController) {
TsuminoDescription(controller)
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
TsuminoDescription(state, openMetadataViewer)
}
}

View File

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
private lateinit var manga: Manga
constructor(target: T, manga: Manga) : this() {
targetController = target
this.manga = manga
}
@Suppress("DEPRECATION")
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_edit_cover)
.setPositiveButton(R.string.action_edit) { _, _ ->
(targetController as? Listener)?.openMangaCoverPicker(manga)
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.action_delete) { _, _ ->
(targetController as? Listener)?.deleteMangaCover(manga)
}
.create()
}
interface Listener {
fun deleteMangaCover(manga: Manga)
fun openMangaCoverPicker(manga: Manga)
}
}

View File

@ -682,7 +682,7 @@ class LibraryPresenter(
manga.artist.takeUnless { it == manga.originalArtist },
manga.description.takeUnless { it == manga.originalDescription },
manga.genre.takeUnless { it == manga.originalGenre }?.let { manga.getGenres() },
manga.status.takeUnless { it == manga.originalStatus },
manga.status.takeUnless { it == manga.originalStatus }?.toLong(),
)
}
if (mangaJson != null) {

View File

@ -474,7 +474,7 @@ class MainActivity : BaseActivity() {
SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false
val fgController = router.backstack.lastOrNull()?.controller as? MangaController
if (fgController?.manga?.id != extras.getLong(MangaController.MANGA_EXTRA)) {
if (fgController?.mangaId != extras.getLong(MangaController.MANGA_EXTRA)) {
router.popToRoot()
setSelectedNavItem(R.id.nav_library)
router.pushController(RouterTransaction.with(MangaController(extras)))
@ -655,6 +655,9 @@ class MainActivity : BaseActivity() {
}
val isFullComposeController = internalTo is FullComposeController<*>
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
if (!isTablet()) {
// Save lift state
if (isPush) {
@ -677,17 +680,6 @@ class MainActivity : BaseActivity() {
}
binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
// TODO: Remove when MangaController is full compose
if (!isFullComposeController) {
binding.appbar.isTransparentWhenNotLifted = internalTo is MangaController
binding.controllerContainer.overlapHeader = internalTo is MangaController
}
} else {
binding.appbar.isVisible = !isFullComposeController
}
}

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.ScrollView
@ -14,9 +13,9 @@ import coil.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.interactor.GetMangaById
import eu.kanade.domain.manga.model.Manga
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.EditMangaDialogBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
@ -28,6 +27,7 @@ import exh.util.dropBlank
import exh.util.trimOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import reactivecircus.flowbinding.android.view.clicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -38,17 +38,13 @@ class EditMangaDialog : DialogController {
private val manga: Manga
private var customCoverUri: Uri? = null
private var willResetCover = false
private val infoController
get() = targetController as MangaController
private val context: Context get() = binding.root.context
constructor(target: MangaController, manga: Manga) : super(
bundleOf(KEY_MANGA to manga.id!!),
bundleOf(KEY_MANGA to manga.id),
) {
targetController = target
this.manga = manga
@ -56,8 +52,7 @@ class EditMangaDialog : DialogController {
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
manga = Injekt.get<DatabaseHelper>().getManga(bundle.getLong(KEY_MANGA))
.executeAsBlocking()!!
manga = runBlocking { Injekt.get<GetMangaById>().await(bundle.getLong(KEY_MANGA))!! }
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
@ -93,9 +88,9 @@ class EditMangaDialog : DialogController {
)
binding.status.adapter = statusAdapter
if (manga.status != manga.originalStatus) {
if (manga.status != manga.ogStatus) {
binding.status.setSelection(
when (manga.status) {
when (manga.status.toInt()) {
SManga.UNKNOWN -> 0
SManga.ONGOING -> 1
SManga.COMPLETED -> 2
@ -117,76 +112,61 @@ class EditMangaDialog : DialogController {
binding.mangaAuthor.setText(manga.author.orEmpty())
binding.mangaArtist.setText(manga.artist.orEmpty())
binding.mangaDescription.setText(manga.description.orEmpty())
binding.mangaGenresTags.setChips(manga.getGenres().orEmpty().dropBlank())
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank())
} else {
if (manga.title != manga.originalTitle) {
if (manga.title != manga.ogTitle) {
binding.title.append(manga.title)
}
if (manga.author != manga.originalAuthor) {
if (manga.author != manga.ogAuthor) {
binding.mangaAuthor.append(manga.author.orEmpty())
}
if (manga.artist != manga.originalArtist) {
if (manga.artist != manga.ogArtist) {
binding.mangaArtist.append(manga.artist.orEmpty())
}
if (manga.description != manga.originalDescription) {
if (manga.description != manga.ogDescription) {
binding.mangaDescription.append(manga.description.orEmpty())
}
binding.mangaGenresTags.setChips(manga.getGenres().orEmpty().dropBlank())
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank())
binding.title.hint = context.getString(R.string.title_hint, manga.originalTitle)
if (manga.originalAuthor != null) {
binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.originalAuthor)
binding.title.hint = context.getString(R.string.title_hint, manga.ogTitle)
if (manga.ogAuthor != null) {
binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.ogAuthor)
}
if (manga.originalArtist != null) {
binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.originalArtist)
if (manga.ogArtist != null) {
binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.ogArtist)
}
if (manga.originalDescription != null) {
if (!manga.ogDescription.isNullOrBlank()) {
binding.mangaDescription.hint =
context.getString(
R.string.description_hint,
manga.originalDescription?.replace(
"\n",
" ",
)?.chop(20),
manga.ogDescription.replace("\n", " ").chop(20),
)
}
}
binding.mangaGenresTags.clearFocus()
/*binding.coverLayout.clicks()
.onEach { infoController.changeCover() }
.launchIn(infoController.viewScope)*/
binding.resetTags.clicks()
.onEach { resetTags() }
.launchIn(infoController.viewScope)
}
private fun resetTags() {
if (manga.genre.isNullOrBlank() || manga.source == LocalSource.ID) {
if (manga.genre.isNullOrEmpty() || manga.source == LocalSource.ID) {
binding.mangaGenresTags.setChips(emptyList())
} else {
binding.mangaGenresTags.setChips(manga.getOriginalGenres().orEmpty())
binding.mangaGenresTags.setChips(manga.ogGenre.orEmpty())
}
}
fun loadCover() {
private fun loadCover() {
val radius = context.resources.getDimension(R.dimen.card_radius)
binding.mangaCover.load(manga) {
transformations(RoundedCornersTransformation(radius))
}
}
fun updateCover(uri: Uri) {
willResetCover = false
val radius = context.resources.getDimension(R.dimen.card_radius)
binding.mangaCover.load(uri) {
transformations(RoundedCornersTransformation(radius))
}
customCoverUri = uri
}
private fun onPositiveButtonClick() {
infoController.presenter.updateMangaInfo(
context,
binding.title.text.toString(),
binding.mangaAuthor.text.toString(),
binding.mangaArtist.text.toString(),
@ -202,9 +182,7 @@ class EditMangaDialog : DialogController {
6 -> SManga.ON_HIATUS
else -> null
}
},
customCoverUri,
willResetCover,
}?.toLong(),
)
}

View File

@ -1,132 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.text.SpannableStringBuilder
import android.view.View
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.ChaptersItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
import eu.kanade.tachiyomi.util.lang.toRelativeString
import exh.metadata.MetadataUtil
import exh.source.isEhBasedManga
import java.util.Date
class ChapterHolder(
view: View,
private val adapter: ChaptersAdapter,
) : BaseChapterHolder(view, adapter) {
private val binding = ChaptersItemBinding.bind(view)
init {
binding.download.listener = downloadActionListener
}
fun bind(item: ChapterItem, manga: Manga) {
val chapter = item.chapter
binding.chapterTitle.text = when (manga.displayMode) {
Manga.CHAPTER_DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
// TODO: show cleaned name consistently around the app
// else -> cleanChapterName(chapter, manga)
}
// Set correct text color
val chapterTitleColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColor
}
binding.chapterTitle.setTextColor(chapterTitleColor)
val chapterDescriptionColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColorSecondary
}
binding.chapterDescription.setTextColor(chapterDescriptionColor)
binding.bookmarkIcon.isVisible = chapter.bookmark
val descriptions = mutableListOf<CharSequence>()
if (chapter.date_upload > 0) {
// SY -->
if (manga.isEhBasedManga()) {
descriptions.add(MetadataUtil.EX_DATE_FORMAT.format(Date(chapter.date_upload)))
} else /* SY <-- */ descriptions.add(Date(chapter.date_upload).toRelativeString(itemView.context, adapter.relativeTime, adapter.dateFormat))
}
if ((!chapter.read || (adapter.preserveReadingPosition && manga.isEhBasedManga())) && chapter.last_page_read > 0) {
val lastPageRead = buildSpannedString {
color(adapter.readColor) {
append(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1))
}
}
descriptions.add(lastPageRead)
}
if (!chapter.scanlator.isNullOrBlank()) {
descriptions.add(chapter.scanlator!!)
}
if (descriptions.isNotEmpty()) {
binding.chapterDescription.text = descriptions.joinTo(SpannableStringBuilder(), "")
} else {
binding.chapterDescription.text = ""
}
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)
}
private fun cleanChapterName(chapter: Chapter, manga: Manga): String {
return chapter.name
.trim()
.removePrefix(manga.title)
.trim(*CHAPTER_TRIM_CHARS)
}
}
private val CHAPTER_TRIM_CHARS = arrayOf(
// Whitespace
' ',
'\u0009',
'\u000A',
'\u000B',
'\u000C',
'\u000D',
'\u0020',
'\u0085',
'\u00A0',
'\u1680',
'\u2000',
'\u2001',
'\u2002',
'\u2003',
'\u2004',
'\u2005',
'\u2006',
'\u2007',
'\u2008',
'\u2009',
'\u200A',
'\u2028',
'\u2029',
'\u202F',
'\u205F',
'\u3000',
// Separators
'-',
'_',
',',
':',
).toCharArray()

View File

@ -1,33 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
class ChapterItem(chapter: Chapter, val manga: Manga) :
BaseChapterItem<ChapterHolder, AbstractHeaderItem<FlexibleViewHolder>>(chapter) {
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
return ChapterHolder(view, adapter as ChaptersAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this, manga)
}
}

View File

@ -1,50 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class ChaptersAdapter(
controller: MangaController,
context: Context,
) : BaseChaptersAdapter<ChapterItem>(controller) {
private val preferences: PreferencesHelper by injectLazy()
var items: List<ChapterItem> = emptyList()
val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
val relativeTime: Int = preferences.relativeTime().get()
val dateFormat: DateFormat = preferences.dateFormat()
// SY -->
val preserveReadingPosition: Boolean = preferences.preserveReadingPosition().get()
// SY <--
override fun updateDataSet(items: List<ChapterItem>?) {
this.items = items ?: emptyList()
super.updateDataSet(items)
}
fun indexOf(item: ChapterItem): Int {
return items.indexOf(item)
}
}

View File

@ -8,21 +8,21 @@ import androidx.core.view.isVisible
import com.bluelinelabs.conductor.Router
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.model.toTriStateGroupState
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.ui.manga.MangaPresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
import exh.md.utils.MdUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class ChaptersSettingsSheet(
private val router: Router,
@ -33,7 +33,7 @@ class ChaptersSettingsSheet(
private var manga: Manga? = null
val filters = Filter(context)
private val filters = Filter(context)
private val sort = Sort(context)
private val display = Display(context)
@ -47,8 +47,14 @@ class ChaptersSettingsSheet(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
scope = MainScope()
// TODO: Listen to changes
updateManga()
scope.launch {
presenter.state
.filterIsInstance<MangaScreenState.Success>()
.collectLatest {
manga = it.manga
getTabViews().forEach { settings -> (settings as Settings).updateView() }
}
}
}
override fun onDetachedFromWindow() {
@ -68,17 +74,13 @@ class ChaptersSettingsSheet(
R.string.action_display,
)
private fun updateManga() {
manga = presenter.manga.toDomainManga()
}
private fun showPopupMenu(view: View) {
view.popupMenu(
menuRes = R.menu.default_chapter_filter,
onMenuItemClick = {
when (itemId) {
R.id.set_as_default -> {
SetChapterSettingsDialog(presenter.manga).showDialog(router)
SetChapterSettingsDialog(presenter.manga!!.toDbManga()).showDialog(router)
}
}
},
@ -101,11 +103,7 @@ class ChaptersSettingsSheet(
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return filterGroup.items.any { it.state != State.IGNORE.value } || presenter.manga.filtered_scanlators != null
}
fun updateScanlatorFilter() {
filterGroup.updateScanlatorFilter()
return filterGroup.items.any { it.state != State.IGNORE.value } || presenter.manga?.filteredScanlators != null
}
override fun updateView() {
@ -135,7 +133,7 @@ class ChaptersSettingsSheet(
unread.state = manga.unreadFilter.toTriStateGroupState().value
bookmarked.state = manga.bookmarkedFilter.toTriStateGroupState().value
// SY -->
updateScanlatorFilter()
scanlatorFilters.isVisible = presenter.allChapterScanlators.size > 1
// SY <--
}
@ -144,16 +142,11 @@ class ChaptersSettingsSheet(
adapter.notifyItemRangeChanged(0, 3)
}
// SY -->
fun updateScanlatorFilter() {
scanlatorFilters.isVisible = presenter.allChapterScanlators.size > 1
}
// SY <--
override fun onItemClicked(item: Item) {
// SY -->
if (item is Item.DrawableSelection) {
val scanlators = presenter.allChapterScanlators.toTypedArray()
val filteredScanlators = presenter.manga.filtered_scanlators?.let(MdUtil::getScanlators) ?: scanlators.toSet()
val filteredScanlators = presenter.manga?.filteredScanlators?.toSet() ?: scanlators.toSet()
val selection = scanlators.map {
it in filteredScanlators
}.toBooleanArray()
@ -164,25 +157,16 @@ class ChaptersSettingsSheet(
selection[which] = selected
}
.setPositiveButton(android.R.string.ok) { _, _ ->
launchIO {
supervisorScope {
val selected = scanlators.filterIndexed { index, s -> selection[index] }.toSet()
presenter.setScanlatorFilter(selected)
withUIContext { onGroupClicked(this@FilterGroup) }
}
}
val selected = scanlators.filterIndexed { index, _ -> selection[index] }
presenter.setScanlatorFilter(selected)
}
.setNegativeButton(R.string.action_reset) { _, _ ->
launchIO {
supervisorScope {
presenter.setScanlatorFilter(presenter.allChapterScanlators)
withUIContext { onGroupClicked(this@FilterGroup) }
}
}
presenter.setScanlatorFilter(presenter.allChapterScanlators)
}
.show()
return
}
// SY <--
item as Item.TriStateGroup
val newState = when (item.state) {
State.IGNORE.value -> State.INCLUDE
@ -196,10 +180,6 @@ class ChaptersSettingsSheet(
bookmarked -> presenter.setBookmarkedFilter(newState)
else -> {}
}
// TODO: Remove
updateManga()
updateView()
}
}
}
@ -254,16 +234,11 @@ class ChaptersSettingsSheet(
override fun onItemClicked(item: Item) {
when (item) {
source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE.toInt())
chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER.toInt())
uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE.toInt())
source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE)
chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER)
uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE)
else -> throw Exception("Unknown sorting")
}
// TODO: Remove
presenter.reverseSortOrder()
updateManga()
updateView()
}
}
}
@ -309,14 +284,10 @@ class ChaptersSettingsSheet(
if (item.checked) return
when (item) {
displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME.toInt())
displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER.toInt())
displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME)
displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER)
else -> throw NotImplementedError("Unknown display mode")
}
// TODO: Remove
updateManga()
updateView()
}
}
}

View File

@ -1,30 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.deleteChapters()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteChapters()
}
}

View File

@ -1,69 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaChaptersHeaderBinding
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
class MangaChaptersHeaderAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<MangaChaptersHeaderAdapter.HeaderViewHolder>() {
private var numChapters: Int? = null
private var hasActiveFilters: Boolean = false
private lateinit var binding: MangaChaptersHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaChaptersHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
fun setNumChapters(numChapters: Int) {
this.numChapters = numChapters
notifyItemChanged(0, this)
}
fun setHasActiveFilters(hasActiveFilters: Boolean) {
this.hasActiveFilters = hasActiveFilters
notifyItemChanged(0, this)
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.chaptersLabel.text = if (numChapters == null) {
view.context.getString(R.string.chapters)
} else {
view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numChapters!!, numChapters)
}
val filterColor = if (hasActiveFilters) {
view.context.getResourceColor(R.attr.colorFilterActive)
} else {
view.context.getResourceColor(R.attr.colorOnBackground)
}
binding.btnChaptersFilter.drawable.setTint(filterColor)
merge(view.clicks(), binding.btnChaptersFilter.clicks())
.onEach { controller.showSettingsSheet() }
.launchIn(controller.viewScope)
}
}
}

View File

@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MangaInfoButtonsBinding
import eu.kanade.tachiyomi.ui.manga.MangaController
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class MangaInfoButtonsAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<MangaInfoButtonsAdapter.HeaderViewHolder>() {
private val preferences: PreferencesHelper by injectLazy()
private lateinit var binding: MangaInfoButtonsBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoButtonsBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// EXH -->
if (controller.smartSearchConfig == null) {
binding.recommendBtn.isVisible = !preferences.recommendsInOverflow().get()
binding.recommendBtn.clicks()
.onEach { controller.openRecommends() }
.launchIn(controller.viewScope)
} else {
if (controller.smartSearchConfig.origMangaId != null) {
binding.mergeBtn.isVisible = true
}
binding.mergeBtn.clicks()
.onEach {
controller.mergeWithAnother()
}
.launchIn(controller.viewScope)
}
// EXH <--
}
}
}

View File

@ -1,362 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.loadAutoPause
import exh.merged.sql.models.MergedMangaReference
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import exh.util.SourceTagsUtil
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.injectLazy
class MangaInfoHeaderAdapter(
private val controller: MangaController,
private val fromSource: Boolean,
private val isTablet: Boolean,
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
private val trackManager: TrackManager by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private var manga: Manga = controller.presenter.manga
private var source: Source = controller.presenter.source
// SY -->
private var meta: RaisedSearchMetadata? = controller.presenter.meta.value
private var mergedMangaReferences: List<MergedMangaReference> = controller.presenter.mergedMangaReferences
// SY <--
private var trackCount: Int = 0
private var metaInfoAdapter: RecyclerView.Adapter<*>? = null
private lateinit var binding: MangaInfoHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
updateCoverPosition()
// Expand manga info if navigated from source listing or explicitly set to
// (e.g. on tablets)
binding.mangaSummarySection.expanded = fromSource || isTablet
// SY -->
metaInfoAdapter = source.getMainSource<MetadataSource<*, *>>()?.getDescriptionAdapter(controller)?.apply {
setHasStableIds(true)
}
binding.metadataView.isVisible = if (metaInfoAdapter != null) {
binding.metadataView.layoutManager = LinearLayoutManager(binding.root.context)
binding.metadataView.adapter = metaInfoAdapter
true
} else {
false
}
// SY <--
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun update(manga: Manga, source: Source, meta: RaisedSearchMetadata?, mergedMangaReferences: List<MergedMangaReference>) {
this.manga = manga
this.source = source
// SY -->
this.meta = meta
this.mergedMangaReferences = mergedMangaReferences
// SY <--
update()
updateMetaAdapter()
}
fun update() {
notifyItemChanged(0, this)
}
fun setTrackingCount(trackCount: Int) {
this.trackCount = trackCount
update()
}
private fun updateCoverPosition() {
if (isTablet) return
val appBarHeight = controller.getMainAppBarHeight()
binding.mangaCover.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin += appBarHeight
}
}
private fun updateMetaAdapter() {
metaInfoAdapter?.notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(controller.viewScope)
if (controller.presenter.manga.favorite) {
binding.btnFavorite.longClicks()
.onEach { controller.onCategoriesClick() }
.launchIn(controller.viewScope)
}
with(binding.btnTracking) {
if (trackManager.hasLoggedServices()) {
isVisible = true
if (trackCount > 0) {
setIconResource(R.drawable.ic_done_24dp)
text = view.context.resources.getQuantityString(
R.plurals.num_trackers,
trackCount,
trackCount,
)
isActivated = true
} else {
setIconResource(R.drawable.ic_sync_24dp)
text = view.context.getString(R.string.manga_tracking_tab)
isActivated = false
}
clicks()
.onEach { controller.onTrackingClick() }
.launchIn(controller.viewScope)
} else {
isVisible = false
}
}
if (controller.presenter.source is HttpSource) {
binding.btnWebview.isVisible = true
binding.btnWebview.clicks()
.onEach {
if (controller.presenter.source.id == MERGED_SOURCE_ID) {
controller.openMergedMangaWebview()
} else controller.openMangaInWebView()
}
.launchIn(controller.viewScope)
}
// SY -->
binding.btnMerge.isVisible = controller.presenter.manga.favorite
binding.btnMerge.clicks()
.onEach { controller.openSmartSearch() }
.launchIn(controller.viewScope)
// SY <--
binding.mangaFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaAuthor.longClicks()
.onEach {
// SY -->
val author = binding.mangaAuthor.text.toString()
controller.activity?.copyToClipboard(
author,
SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = author) ?: author,
)
// SY <--
}
.launchIn(controller.viewScope)
binding.mangaAuthor.clicks()
.onEach {
// SY -->
val author = binding.mangaAuthor.text.toString()
controller.performGlobalSearch(SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = author) ?: author)
// SY <--
}
.launchIn(controller.viewScope)
binding.mangaArtist.longClicks()
.onEach {
// SY -->
val artist = binding.mangaArtist.text.toString()
controller.activity?.copyToClipboard(
artist,
SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = artist) ?: artist,
)
// SY <--
}
.launchIn(controller.viewScope)
binding.mangaArtist.clicks()
.onEach {
// SY -->
val artist = binding.mangaArtist.text.toString()
controller.performGlobalSearch(SourceTagsUtil.getWrappedTag(source.id, namespace = "artist", tag = artist) ?: artist)
// SY <--
}
.launchIn(controller.viewScope)
binding.mangaCover.clicks()
.onEach {
controller.showFullCoverDialog()
}
.launchIn(controller.viewScope)
setMangaInfo()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo() {
// Update full title TextView.
binding.mangaFullTitle.text = manga.title.ifBlank { view.context.getString(R.string.unknown) }
// Update author TextView.
binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
view.context.getString(R.string.unknown_author)
} else {
manga.author
}
// Update artist TextView.
val hasArtist = !manga.artist.isNullOrBlank() && manga.artist != manga.author
binding.mangaArtist.isVisible = hasArtist
if (hasArtist) {
binding.mangaArtist.text = manga.artist
}
// If manga source is known update source TextView.
binding.mangaMissingSourceIcon.isVisible = source is SourceManager.StubSource
with(binding.mangaSource) {
text = source.getNameForMangaInfo(source, ::getMergedSourcesString)
setOnClickListener {
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
}
// Update manga status.
val (statusDrawable, statusString) = when (manga.status) {
SManga.ONGOING -> R.drawable.ic_status_ongoing_24dp to R.string.ongoing
SManga.COMPLETED -> R.drawable.ic_status_completed_24dp to R.string.completed
SManga.LICENSED -> R.drawable.ic_status_licensed_24dp to R.string.licensed
SManga.PUBLISHING_FINISHED, 61 -> R.drawable.ic_done_24dp to R.string.publishing_finished
SManga.CANCELLED, 62 -> R.drawable.ic_close_24dp to R.string.cancelled
SManga.ON_HIATUS, 63 -> R.drawable.ic_pause_24dp to R.string.on_hiatus
else -> R.drawable.ic_status_unknown_24dp to R.string.unknown
}
binding.mangaStatusIcon.setImageResource(statusDrawable)
binding.mangaStatus.setText(statusString)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if changed.
binding.backdrop.loadAutoPause(manga)
binding.mangaCover.loadAutoPause(manga)
// Manga info section
// SY -->
binding.mangaSummarySection.setTags(
manga.getGenres(),
meta,
controller::performGenreSearch,
controller::performGlobalSearch,
source,
)
// SY <--
binding.mangaSummarySection.description = manga.description
binding.mangaSummarySection.isVisible = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank()
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
val (iconResource, stringResource) = when (isFavorite) {
true -> R.drawable.ic_favorite_24dp to R.string.in_library
false -> R.drawable.ic_favorite_border_24dp to R.string.add_to_library
}
binding.btnFavorite.apply {
setIconResource(iconResource)
text = context.getString(stringResource)
isActivated = isFavorite
}
}
// SY -->
private fun getMergedSourcesString(enabledLangs: List<String>, onlyName: Boolean = false): String {
return if (onlyName) {
mergedMangaReferences.map {
val source = sourceManager.getOrStub(it.mangaSourceId)
if (source.lang !in enabledLangs) {
source.toString()
} else {
source.name
}
}.distinct().joinToString()
} else {
mergedMangaReferences.map {
sourceManager.getOrStub(it.mangaSourceId).toString()
}.distinct().joinToString()
}
}
// SY <--
}
}

View File

@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.View
import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.chip.Chip
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.MangaInfoGenreGroupingBinding
import eu.kanade.tachiyomi.util.system.dpToPx
import exh.util.makeSearchChip
class NamespaceTagsHolder(
view: View,
adapter: FlexibleAdapter<*>,
) : FlexibleViewHolder(view, adapter) {
val binding = MangaInfoGenreGroupingBinding.bind(view)
fun bind(item: NamespaceTagsItem) {
binding.namespace.removeAllViews()
val namespace = item.namespace
binding.namespace.isVisible = if (namespace != null) {
binding.namespace.addView(
Chip(binding.root.context).apply {
text = namespace
},
)
binding.tags.updateLayoutParams<LinearLayout.LayoutParams> {
marginStart = 8.dpToPx
}
true
} else {
binding.tags.updateLayoutParams<LinearLayout.LayoutParams> {
marginStart = 0.dpToPx
}
false
}
binding.tags.removeAllViews()
item.tags.map { (tag, type) ->
binding.root.context.makeSearchChip(
tag,
item.onClick,
item.onLongClick,
item.source.id,
namespace,
type,
)
}.forEach {
binding.tags.addView(it)
}
}
}

View File

@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import tachiyomi.source.Source
class NamespaceTagsItem(
val namespace: String?,
val tags: List<Pair<String, Int?>>,
val onClick: (item: String) -> Unit,
val onLongClick: (item: String) -> Unit,
val source: Source,
) :
AbstractFlexibleItem<NamespaceTagsHolder>() {
override fun getLayoutRes(): Int {
return R.layout.manga_info_genre_grouping
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): NamespaceTagsHolder {
return NamespaceTagsHolder(view, adapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: NamespaceTagsHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as NamespaceTagsItem
if (namespace != other.namespace) return false
return true
}
override fun hashCode(): Int {
return namespace.hashCode()
}
}

View File

@ -80,7 +80,7 @@ class TrackSearchDialog : DialogController {
// Do an initial search based on the manga's title
if (savedViewState == null) {
currentlySearched = trackController.presenter.manga.title
currentlySearched = trackController.presenter.manga!!.title
binding!!.titleInput.editText?.append(currentlySearched)
}
search(currentlySearched)

View File

@ -10,6 +10,7 @@ import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
@ -25,7 +26,7 @@ import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
class TrackSheet(
val controller: MangaController,
val fragmentManager: FragmentManager,
private val fragmentManager: FragmentManager,
) : BaseBottomSheetDialog(controller.activity!!),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
@ -74,8 +75,8 @@ class TrackSheet(
override fun onSetClick(position: Int) {
val item = adapter.getItem(position) ?: return
val manga = controller.presenter.manga
val source = controller.presenter.source
val manga = controller.presenter.manga?.toDbManga() ?: return
val source = controller.presenter.source ?: return
if (item.service is EnhancedTrackService) {
if (item.track != null) {

View File

@ -34,7 +34,7 @@ class HistoryController : ComposeController<HistoryPresenter>(), RootController
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickCover = { history ->
router.pushController(MangaController(history))
router.pushController(MangaController(history.id))
},
onClickResume = { history ->
presenter.getNextChapterForManga(history.mangaId, history.chapterId)

View File

@ -7,6 +7,7 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
@ -48,19 +49,18 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
return coverCache.getCustomCoverFile(id).exists()
}
fun Manga.removeCovers(coverCache: CoverCache) {
if (isLocal()) return
fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
if (isLocal()) return 0
cover_last_modified = Date().time
coverCache.deleteFromCache(this, true)
}
fun Manga.updateCoverLastModified(db: DatabaseHelper) {
cover_last_modified = Date().time
db.updateMangaCoverLastModified(this).executeAsBlocking()
return coverCache.deleteFromCache(this, true)
}
fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
return toDomainManga()?.shouldDownloadNewChapters(db, prefs) ?: false
}
fun DomainManga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
if (!favorite) return false
// Boolean to determine if user wants to automatically download new chapters.
@ -75,7 +75,7 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
// Get all categories, else default category (0)
val categoriesForManga =
db.getCategoriesForManga(this).executeAsBlocking()
db.getCategoriesForManga(toDbManga()).executeAsBlocking()
.mapNotNull { it.id }
.takeUnless { it.isEmpty() } ?: listOf(0)

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.util.chapter
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -34,6 +35,18 @@ object ChapterSettingsHelper {
db.updateChapterFlags(manga).executeAsBlocking()
}
suspend fun applySettingDefaults(mangaId: Long, setMangaChapterFlags: SetMangaChapterFlags) {
setMangaChapterFlags.awaitSetAllFlags(
mangaId = mangaId,
unreadFilter = prefs.filterChapterByRead().toLong(),
downloadedFilter = prefs.filterChapterByDownloaded().toLong(),
bookmarkedFilter = prefs.filterChapterByBookmarked().toLong(),
sortingMode = prefs.sortChapterBySourceOrNumber().toLong(),
sortingDirection = prefs.sortChapterByAscendingOrDescending().toLong(),
displayMode = prefs.displayChapterByNameOrNumber().toLong(),
)
}
/**
* Updates all mangas in library with global Chapter Settings.
*/

View File

@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
import eu.kanade.domain.manga.model.Manga as DomainManga
fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
return when (manga.sorting) {
@ -20,3 +23,28 @@ fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending(
else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}")
}
}
fun getChapterSort(
manga: DomainManga,
sortDescending: Boolean = manga.sortDescending(),
): (DomainChapter, DomainChapter) -> Int {
return when (manga.sorting) {
DomainManga.CHAPTER_SORTING_SOURCE -> when (sortDescending) {
true -> { c1, c2 -> c1.sourceOrder.compareTo(c2.sourceOrder) }
false -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
}
DomainManga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
true -> { c1, c2 ->
c2.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c1.chapterNumber.toString())
}
false -> { c1, c2 ->
c1.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c2.chapterNumber.toString())
}
}
DomainManga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
true -> { c1, c2 -> c2.dateUpload.compareTo(c1.dateUpload) }
false -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
}

View File

@ -1,304 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.recyclerview.widget.LinearLayoutManager
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaSummaryBinding
import eu.kanade.tachiyomi.ui.manga.info.NamespaceTagsItem
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.util.setChipsExtended
import tachiyomi.source.Source
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MangaSummaryView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
private val binding = MangaSummaryBinding.inflate(LayoutInflater.from(context), this, true)
private var animatorSet: AnimatorSet? = null
private var recalculateHeights = false
private var descExpandedHeight = -1
private var descShrunkHeight = -1
// SY -->
private var mangaTagsInfoAdapter = FlexibleAdapter<NamespaceTagsItem>(emptyList())
// SY <--
var expanded = false
set(value) {
if (field != value) {
field = value
updateExpandState()
}
}
var description: CharSequence? = null
set(value) {
if (field != value) {
field = if (value.isNullOrBlank()) {
context.getString(R.string.unknown)
// SY -->
} else if (value == "meta") {
""
// SY <--
} else {
value
}
binding.descriptionText.text = field
recalculateHeights = true
doOnNextLayout {
updateExpandState()
}
if (!isInLayout) {
requestLayout()
}
}
}
// SY -->
fun setTags(
items: List<String>?,
meta: RaisedSearchMetadata?,
onClick: (item: String) -> Unit,
onLongClick: (item: String) -> Unit,
source: Source,
) {
binding.tagChipsShrunk.setChipsExtended(
items,
onClick,
onLongClick,
source.id,
)
// binding.tagChipsExpanded.setChips(items, onClick)
setChipsWithNamespace(
items,
meta,
onClick,
onLongClick,
source,
)
}
// SY <--
private fun updateExpandState() = binding.apply {
val initialSetup = descriptionText.maxHeight < 0
val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight
val maxHeightStart = if (initialSetup) maxHeightTarget else descriptionText.maxHeight
val descMaxHeightAnimator = ValueAnimator().apply {
setIntValues(maxHeightStart, maxHeightTarget)
addUpdateListener {
descriptionText.maxHeight = it.animatedValue as Int
}
}
val toggleDrawable = ContextCompat.getDrawable(
context,
if (expanded) R.drawable.anim_caret_up else R.drawable.anim_caret_down,
)
toggleMore.setImageDrawable(toggleDrawable)
var pastHalf = false
val toggleTarget = if (expanded) 1F else 0F
val toggleStart = if (initialSetup) {
toggleTarget
} else {
toggleMore.translationY / toggleMore.height
}
val toggleAnimator = ValueAnimator().apply {
setFloatValues(toggleStart, toggleTarget)
addUpdateListener {
val value = it.animatedValue as Float
toggleMore.translationY = toggleMore.height * value
descriptionScrim.translationY = toggleMore.translationY
toggleMoreScrim.translationY = toggleMore.translationY
tagChipsShrunkContainer.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
tagChipsExpanded.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
// Update non-animatable objects mid-animation makes it feel less abrupt
if (it.animatedFraction >= 0.5F && !pastHalf) {
pastHalf = true
descriptionText.text = trimWhenNeeded(description)
tagChipsShrunkContainer.scrollX = 0
tagChipsShrunkContainer.isVisible = !expanded
tagChipsExpanded.isVisible = expanded
}
}
}
animatorSet?.cancel()
animatorSet = AnimatorSet().apply {
interpolator = FastOutSlowInInterpolator()
duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong()
playTogether(toggleAnimator, descMaxHeightAnimator)
start()
}
(toggleDrawable as? Animatable)?.start()
}
private fun trimWhenNeeded(text: CharSequence?): CharSequence? {
return if (!expanded) {
text
?.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
?.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
} else {
text
}
}
// SY -->
private fun setChipsWithNamespace(
genre: List<String>?,
meta: RaisedSearchMetadata?,
onClick: (item: String) -> Unit,
onLongClick: (item: String) -> Unit,
source: Source,
) {
val namespaceTags = when {
meta != null -> {
meta.tags
.filterNot { it.type == RaisedSearchMetadata.TAG_TYPE_VIRTUAL }
.groupBy { it.namespace }
.map { (namespace, tags) ->
NamespaceTagsItem(
namespace,
tags.map {
it.name to it.type
},
onClick,
onLongClick,
source,
)
}
}
genre != null -> {
if (genre.all { it.contains(':') }) {
genre
.map { tag ->
val index = tag.indexOf(':')
tag.substring(0, index).trim() to tag.substring(index + 1).trim()
}
.groupBy {
it.first
}
.mapValues { group ->
group.value.map { it.second to 0 }
}
.map { (namespace, tags) ->
NamespaceTagsItem(
namespace,
tags,
onClick,
onLongClick,
source,
)
}
} else {
listOf(
NamespaceTagsItem(
null,
genre.map { it to null },
onClick,
onLongClick,
source,
),
)
}
}
else -> emptyList()
}
mangaTagsInfoAdapter.updateDataSet(namespaceTags)
}
// SY <--
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Wait until parent view has determined the exact width
// because this affect the description line count
val measureWidthFreely = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY
if (!recalculateHeights || measureWidthFreely) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
recalculateHeights = false
// Measure with expanded lines
binding.descriptionText.maxLines = Int.MAX_VALUE
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descExpandedHeight = binding.descriptionText.measuredHeight
// Measure with shrunk lines
binding.descriptionText.maxLines = SHRUNK_DESC_MAX_LINES
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descShrunkHeight = binding.descriptionText.measuredHeight
}
init {
binding.descriptionText.apply {
// So that 1 line of text won't be hidden by scrim
minLines = DESC_MIN_LINES
setOnLongClickListener {
description?.let {
context.copyToClipboard(
context.getString(R.string.description),
it.toString(),
)
}
true
}
}
// SY -->
binding.tagChipsExpanded.layoutManager = LinearLayoutManager(binding.root.context)
binding.tagChipsExpanded.adapter = mangaTagsInfoAdapter
mangaTagsInfoAdapter.mItemClickListener = FlexibleAdapter.OnItemClickListener { _, _ ->
expanded = !expanded
false
}
// SY <--
arrayOf(
binding.descriptionText,
binding.descriptionScrim,
binding.toggleMoreScrim,
binding.toggleMore,
).forEach {
it.setOnClickListener { expanded = !expanded }
}
}
}
private const val TOGGLE_ANIM_DURATION = 300L
private const val DESC_MIN_LINES = 2
private const val SHRUNK_DESC_MAX_LINES = 3

View File

@ -4,6 +4,7 @@ import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
@ -11,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding
import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
fun MaterialAlertDialogBuilder.setTextInput(
hint: String? = null,
@ -71,3 +74,19 @@ fun MaterialAlertDialogBuilder.setQuadStateMultiChoiceItems(
}
return setView(binding.root)
}
suspend fun MaterialAlertDialogBuilder.await(
@StringRes positiveLabelId: Int,
@StringRes negativeLabelId: Int,
@StringRes neutralLabelId: Int? = null,
) = suspendCancellableCoroutine<Int> { cont ->
setPositiveButton(positiveLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_POSITIVE) }
setNegativeButton(negativeLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEGATIVE) }
if (neutralLabelId != null) {
setNeutralButton(neutralLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEUTRAL) }
}
setOnDismissListener { cont.cancel() }
val dialog = show()
cont.invokeOnCancellation { dialog.dismiss() }
}

View File

@ -3,8 +3,8 @@ package exh.md.similar
import android.os.Bundle
import android.view.Menu
import androidx.core.os.bundleOf
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
@ -16,7 +16,7 @@ class MangaDexSimilarController(bundle: Bundle) : BrowseSourceController(bundle)
constructor(manga: Manga, source: CatalogueSource) : this(
bundleOf(
MANGA_ID to manga.id!!,
MANGA_ID to manga.id,
MANGA_TITLE to manga.title,
SOURCE_ID_KEY to source.id,
),

View File

@ -4,8 +4,8 @@ import android.os.Bundle
import android.view.Menu
import android.view.View
import androidx.core.os.bundleOf
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController
@ -19,7 +19,7 @@ class RecommendsController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(manga: Manga, source: CatalogueSource) : this(
bundleOf(
MANGA_ID to manga.id!!,
MANGA_ID to manga.id,
SOURCE_ID_KEY to source.id,
),
)

View File

@ -1,5 +1,6 @@
package exh.ui.base
import android.os.Bundle
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
@ -7,26 +8,36 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import nucleus.presenter.Presenter
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@Suppress("DEPRECATION", "unused")
open class CoroutinePresenter<V>(
scope: CoroutineScope = MainScope(),
) : Presenter<V>(), CoroutineScope by scope {
private val scope: () -> CoroutineScope = ::MainScope,
) : Presenter<V>() {
var presenterScope: CoroutineScope = scope()
@CallSuper
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (!presenterScope.isActive) {
presenterScope = scope()
}
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Use launchInView, Flow.inView, Flow.mapView")
override fun getView(): V? {
return super.getView()
}
fun launchInView(block: (CoroutineScope, V) -> Unit) = launchUI {
fun launchInView(block: (CoroutineScope, V) -> Unit) = presenterScope.launchUI {
view?.let { block.invoke(this, it) }
}
@ -44,14 +55,14 @@ open class CoroutinePresenter<V>(
}
}
fun Flow<*>.launchUnderContext(context: CoroutineContext = EmptyCoroutineContext) =
launch(context) { this@launchUnderContext.collect() }
fun Flow<*>.launchUnderContext(context: CoroutineContext = EmptyCoroutineContext) = flowOn(context)
.launch()
fun Flow<*>.launch() = launchIn(this@CoroutinePresenter)
fun Flow<*>.launch() = launchIn(presenterScope)
@CallSuper
override fun destroy() {
super.destroy()
cancel()
override fun onDestroy() {
super.onDestroy()
presenterScope.cancel()
}
}

View File

@ -6,31 +6,30 @@ import android.view.View
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.domain.manga.interactor.GetMangaById
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.databinding.MetadataViewControllerBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.manga.MangaController
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.coroutines.runBlocking
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MetadataViewController : NucleusController<MetadataViewControllerBinding, MetadataViewPresenter> {
constructor(manga: Manga?) : super(
constructor(manga: Manga) : super(
bundleOf(
MangaController.MANGA_EXTRA to (manga?.id ?: 0),
MangaController.MANGA_EXTRA to manga.id,
),
) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking(),
runBlocking { Injekt.get<GetMangaById>().await(mangaId)!! },
)
@Suppress("unused")

View File

@ -1,17 +1,16 @@
package exh.ui.metadata
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.data.DatabaseHandler
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.lang.launchIO
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.getFlatMetadataForManga
import exh.metadata.metadata.base.awaitFlatMetadataForManga
import exh.source.getMainSource
import exh.ui.base.CoroutinePresenter
import exh.util.executeOnIO
import kotlinx.coroutines.flow.MutableStateFlow
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -20,7 +19,7 @@ class MetadataViewPresenter(
val manga: Manga,
val source: Source,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val db: DatabaseHandler = Injekt.get(),
) : CoroutinePresenter<MetadataViewController>() {
val meta = MutableStateFlow<RaisedSearchMetadata?>(null)
@ -29,7 +28,7 @@ class MetadataViewPresenter(
super.onCreate(savedState)
launchIO {
val flatMetadata = db.getFlatMetadataForManga(manga.id!!).executeOnIO() ?: return@launchIO
val flatMetadata = db.awaitFlatMetadataForManga(manga.id) ?: return@launchIO
val mainSource = source.getMainSource<MetadataSource<*, *>>()
if (mainSource != null) {
meta.value = flatMetadata.raise(mainSource.metaClass)

View File

@ -2,132 +2,29 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterEhBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
class EHentaiDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<EHentaiDescriptionAdapter.EHentaiDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterEhBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EHentaiDescriptionViewHolder {
binding = DescriptionAdapterEhBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return EHentaiDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: EHentaiDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class EHentaiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is EHentaiSearchMetadata) return
binding.genre.text =
meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }
?.let {
binding.genre.setBackgroundColor(it.first)
it.second
}
?: meta.genre
?: itemView.context.getString(R.string.unknown)
binding.visible.text = itemView.context.getString(R.string.is_visible, meta.visible ?: itemView.context.getString(R.string.unknown))
binding.favorites.text = (meta.favorites ?: 0).toString()
binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp)
binding.uploader.text = meta.uploader ?: itemView.context.getString(R.string.unknown)
binding.size.text = MetadataUtil.humanReadableByteCount(meta.size ?: 0, true)
binding.size.bindDrawable(itemView.context, R.drawable.ic_outline_sd_card_24)
val length = meta.length ?: 0
binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, length, length)
binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24)
val language = meta.language ?: itemView.context.getString(R.string.unknown)
binding.language.text = if (meta.translated == true) {
itemView.context.getString(R.string.language_translated, language)
} else {
language
}
val ratingFloat = meta.averageRating?.toFloat()
binding.ratingBar.rating = ratingFloat ?: 0F
@SuppressLint("SetTextI18n")
binding.rating.text = (ratingFloat ?: 0F).toString() + " - " + MetadataUtil.getRatingString(itemView.context, ratingFloat?.times(2))
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.favorites,
binding.genre,
binding.language,
binding.pages,
binding.rating,
binding.uploader,
binding.visible,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.uploader.setOnClickListener {
meta.uploader?.let { controller.performSearch("uploader:\"$it\"") }
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun EHentaiDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
EHentaiDescription(controller = controller, meta = meta)
}
@Composable
private fun EHentaiDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun EHentaiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterEhBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is EHentaiSearchMetadata) return@AndroidView
val binding = DescriptionAdapterEhBinding.bind(it)
@ -187,15 +84,11 @@ private fun EHentaiDescription(controller: MangaController, meta: RaisedSearchMe
}
binding.uploader.setOnClickListener {
meta.uploader?.let { controller.performSearch("uploader:\"$it\"") }
meta.uploader?.let { search("uploader:\"$it\"") }
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -1,84 +1,28 @@
package exh.ui.metadata.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapter8mBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.bindDrawable
import exh.metadata.metadata.EightMusesSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
class EightMusesDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<EightMusesDescriptionAdapter.EightMusesDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapter8mBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EightMusesDescriptionViewHolder {
binding = DescriptionAdapter8mBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return EightMusesDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: EightMusesDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class EightMusesDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is EightMusesSearchMetadata) return
binding.title.text = meta.title ?: itemView.context.getString(R.string.unknown)
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
binding.title.setOnLongClickListener {
itemView.context.copyToClipboard(
binding.title.text.toString(),
binding.title.text.toString(),
)
true
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun EightMusesDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
EightMusesDescription(controller = controller, meta = meta)
}
@Composable
private fun EightMusesDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun EightMusesDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapter8mBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is EightMusesSearchMetadata) return@AndroidView
val binding = DescriptionAdapter8mBinding.bind(it)
@ -95,11 +39,7 @@ private fun EightMusesDescription(controller: MangaController, meta: RaisedSearc
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -1,85 +1,28 @@
package exh.ui.metadata.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterHbBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.bindDrawable
import exh.metadata.metadata.HBrowseSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
class HBrowseDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<HBrowseDescriptionAdapter.HBrowseDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterHbBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HBrowseDescriptionViewHolder {
binding = DescriptionAdapterHbBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HBrowseDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HBrowseDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class HBrowseDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is HBrowseSearchMetadata) return
binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.length ?: 0, meta.length ?: 0)
binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24)
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
binding.pages.setOnLongClickListener {
itemView.context.copyToClipboard(
binding.pages.text.toString(),
binding.pages.text.toString(),
)
true
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun HBrowseDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
HBrowseDescription(controller = controller, meta = meta)
}
@Composable
private fun HBrowseDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun HBrowseDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterHbBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is HBrowseSearchMetadata) return@AndroidView
val binding = DescriptionAdapterHbBinding.bind(it)
@ -97,11 +40,7 @@ private fun HBrowseDescription(controller: MangaController, meta: RaisedSearchMe
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -1,98 +1,30 @@
package exh.ui.metadata.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterHiBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.HitomiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import java.util.Date
class HitomiDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<HitomiDescriptionAdapter.HitomiDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterHiBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HitomiDescriptionViewHolder {
binding = DescriptionAdapterHiBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HitomiDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HitomiDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class HitomiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is HitomiSearchMetadata) return
binding.genre.text = meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let {
binding.genre.setBackgroundColor(it.first)
it.second
} ?: meta.genre ?: itemView.context.getString(R.string.unknown)
binding.whenPosted.text = MetadataUtil.EX_DATE_FORMAT.format(Date(meta.uploadDate ?: 0))
binding.language.text = meta.language ?: itemView.context.getString(R.string.unknown)
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.genre,
binding.language,
binding.whenPosted,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun HitomiDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
HitomiDescription(controller = controller, meta = meta)
}
@Composable
private fun HitomiDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun HitomiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterHiBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is HitomiSearchMetadata) return@AndroidView
val binding = DescriptionAdapterHiBinding.bind(it)
@ -121,11 +53,7 @@ private fun HitomiDescription(controller: MangaController, meta: RaisedSearchMet
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -2,94 +2,31 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterMdBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil.getRatingString
import exh.metadata.bindDrawable
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import kotlin.math.round
class MangaDexDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<MangaDexDescriptionAdapter.MangaDexDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterMdBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MangaDexDescriptionViewHolder {
binding = DescriptionAdapterMdBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MangaDexDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: MangaDexDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class MangaDexDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is MangaDexSearchMetadata) return
// todo
val ratingFloat = meta.rating
binding.ratingBar.rating = ratingFloat?.div(2F) ?: 0F
@SuppressLint("SetTextI18n")
binding.rating.text = (round((ratingFloat ?: 0F) * 100.0) / 100.0).toString() + " - " + getRatingString(itemView.context, ratingFloat)
binding.rating.isVisible = ratingFloat != null
binding.ratingBar.isVisible = ratingFloat != null
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
binding.rating.setOnLongClickListener {
itemView.context.copyToClipboard(
binding.rating.text.toString(),
binding.rating.text.toString(),
)
true
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun MangaDexDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
MangaDexDescription(controller = controller, meta = meta)
}
@Composable
private fun MangaDexDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun MangaDexDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterMdBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is MangaDexSearchMetadata) return@AndroidView
val binding = DescriptionAdapterMdBinding.bind(it)
@ -112,11 +49,7 @@ private fun MangaDexDescription(controller: MangaController, meta: RaisedSearchM
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -2,116 +2,30 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterNhBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import java.util.Date
class NHentaiDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<NHentaiDescriptionAdapter.NHentaiDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterNhBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NHentaiDescriptionViewHolder {
binding = DescriptionAdapterNhBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NHentaiDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: NHentaiDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class NHentaiDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is NHentaiSearchMetadata) return
binding.genre.text = meta.tags.filter { it.namespace == NHentaiSearchMetadata.NHENTAI_CATEGORIES_NAMESPACE }.let { tags ->
if (tags.isNotEmpty()) tags.joinToString(transform = { it.name }) else null
}.let { categoriesString ->
categoriesString?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let {
binding.genre.setBackgroundColor(it.first)
it.second
} ?: categoriesString ?: itemView.context.getString(R.string.unknown)
}
meta.favoritesCount?.let {
if (it == 0L) return@let
binding.favorites.text = it.toString()
binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp)
}
binding.whenPosted.text = MetadataUtil.EX_DATE_FORMAT.format(Date((meta.uploadDate ?: 0) * 1000))
binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.pageImageTypes.size, meta.pageImageTypes.size)
binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24)
@SuppressLint("SetTextI18n")
binding.id.text = "#" + (meta.nhId ?: 0)
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.favorites,
binding.genre,
binding.id,
binding.pages,
binding.whenPosted,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun NHentaiDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
NHentaiDescription(controller = controller, meta = meta)
}
@Composable
private fun NHentaiDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun NHentaiDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterNhBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is NHentaiSearchMetadata) return@AndroidView
val binding = DescriptionAdapterNhBinding.bind(it)
@ -157,11 +71,7 @@ private fun NHentaiDescription(controller: MangaController, meta: RaisedSearchMe
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -2,106 +2,31 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterPeBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.PervEdenSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import java.util.Locale
import kotlin.math.round
class PervEdenDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<PervEdenDescriptionAdapter.PervEdenDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterPeBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PervEdenDescriptionViewHolder {
binding = DescriptionAdapterPeBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PervEdenDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: PervEdenDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class PervEdenDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is PervEdenSearchMetadata) return
binding.genre.text = meta.genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let {
binding.genre.setBackgroundColor(it.first)
it.second
} ?: meta.genre ?: itemView.context.getString(R.string.unknown)
val language = meta.lang
binding.language.text = if (language != null) {
val local = Locale(language)
local.displayName
} else itemView.context.getString(R.string.unknown)
binding.ratingBar.rating = meta.rating ?: 0F
@SuppressLint("SetTextI18n")
binding.rating.text = (round((meta.rating ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, meta.rating?.times(2))
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.genre,
binding.language,
binding.rating,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun PervEdenDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
PervEdenDescription(controller = controller, meta = meta)
}
@Composable
private fun PervEdenDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun PervEdenDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterPeBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is PervEdenSearchMetadata) return@AndroidView
val binding = DescriptionAdapterPeBinding.bind(it)
@ -137,11 +62,7 @@ private fun PervEdenDescription(controller: MangaController, meta: RaisedSearchM
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -2,112 +2,30 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterPuBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.PururinSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import kotlin.math.round
class PururinDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<PururinDescriptionAdapter.PururinDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterPuBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PururinDescriptionViewHolder {
binding = DescriptionAdapterPuBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PururinDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: PururinDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class PururinDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is PururinSearchMetadata) return
binding.genre.text = meta.tags.find { it.namespace == PururinSearchMetadata.TAG_NAMESPACE_CATEGORY }.let { genre ->
genre?.let { MetadataUtil.getGenreAndColour(itemView.context, it.name) }?.let {
binding.genre.setBackgroundColor(it.first)
it.second
} ?: genre?.name ?: itemView.context.getString(R.string.unknown)
}
binding.uploader.text = meta.uploaderDisp ?: meta.uploader.orEmpty()
binding.size.text = meta.fileSize ?: itemView.context.getString(R.string.unknown)
binding.size.bindDrawable(itemView.context, R.drawable.ic_outline_sd_card_24)
binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.pages ?: 0, meta.pages ?: 0)
binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24)
val ratingFloat = meta.averageRating?.toFloat()
binding.ratingBar.rating = ratingFloat ?: 0F
@SuppressLint("SetTextI18n")
binding.rating.text = (round((ratingFloat ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, ratingFloat?.times(2))
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.genre,
binding.pages,
binding.rating,
binding.size,
binding.uploader,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun PururinDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
PururinDescription(controller = controller, meta = meta)
}
@Composable
private fun PururinDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun PururinDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterPuBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is PururinSearchMetadata) return@AndroidView
val binding = DescriptionAdapterPuBinding.bind(it)
@ -150,11 +68,7 @@ private fun PururinDescription(controller: MangaController, meta: RaisedSearchMe
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -2,113 +2,31 @@ package exh.ui.metadata.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterTsBinding
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.MetadataUtil
import exh.metadata.bindDrawable
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.ui.metadata.MetadataViewController
import java.util.Date
import kotlin.math.round
class TsuminoDescriptionAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<TsuminoDescriptionAdapter.TsuminoDescriptionViewHolder>() {
private lateinit var binding: DescriptionAdapterTsBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TsuminoDescriptionViewHolder {
binding = DescriptionAdapterTsBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TsuminoDescriptionViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: TsuminoDescriptionViewHolder, position: Int) {
holder.bind()
}
inner class TsuminoDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
val meta = controller.presenter.meta.value
if (meta == null || meta !is TsuminoSearchMetadata) return
binding.genre.text = meta.category?.let { MetadataUtil.getGenreAndColour(itemView.context, it) }?.let {
binding.genre.setBackgroundColor(it.first)
it.second
} ?: meta.category ?: itemView.context.getString(R.string.unknown)
binding.favorites.text = (meta.favorites ?: 0).toString()
binding.favorites.bindDrawable(itemView.context, R.drawable.ic_book_24dp)
binding.whenPosted.text = TsuminoSearchMetadata.TSUMINO_DATE_FORMAT.format(Date(meta.uploadDate ?: 0))
binding.uploader.text = meta.uploader ?: itemView.context.getString(R.string.unknown)
binding.pages.text = itemView.resources.getQuantityString(R.plurals.num_pages, meta.length ?: 0, meta.length ?: 0)
binding.pages.bindDrawable(itemView.context, R.drawable.ic_baseline_menu_book_24)
binding.ratingBar.rating = meta.averageRating ?: 0F
@SuppressLint("SetTextI18n")
binding.rating.text = (round((meta.averageRating ?: 0F) * 100.0) / 100.0).toString() + " - " + MetadataUtil.getRatingString(itemView.context, meta.averageRating?.times(2))
binding.moreInfo.bindDrawable(itemView.context, R.drawable.ic_info_24dp)
listOf(
binding.favorites,
binding.genre,
binding.pages,
binding.rating,
binding.uploader,
binding.whenPosted,
).forEach { textView ->
textView.setOnLongClickListener {
itemView.context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
}
}
}
}
@Composable
fun TsuminoDescription(controller: MangaController) {
val meta by controller.presenter.meta.collectAsState()
TsuminoDescription(controller = controller, meta = meta)
}
@Composable
private fun TsuminoDescription(controller: MangaController, meta: RaisedSearchMetadata?) {
fun TsuminoDescription(state: MangaScreenState.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterTsBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is TsuminoSearchMetadata) return@AndroidView
val binding = DescriptionAdapterTsBinding.bind(it)
@ -151,11 +69,7 @@ private fun TsuminoDescription(controller: MangaController, meta: RaisedSearchMe
}
binding.moreInfo.setOnClickListener {
controller.router?.pushController(
MetadataViewController(
controller.manga,
),
)
openMetadataViewer()
}
},
)

View File

@ -22,7 +22,7 @@ class SmartSearchPresenter(private val source: CatalogueSource, private val conf
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
launchIO {
presenterScope.launchIO {
val result = try {
val resultManga = smartSearchEngine.smartSearch(source, config.origTitle)
if (resultManga != null) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<Button
android:id="@+id/merge_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp"
android:layout_weight="1"
android:text="@string/merge_with_another_source"
android:visibility="gone"
tools:visibility="visible" />
<Button
android:id="@+id/recommend_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp"
android:layout_weight="1"
android:text="@string/az_recommends"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp">
<com.google.android.material.chip.ChipGroup
android:id="@+id/namespace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:chipSpacingHorizontal="4dp"/>
<com.google.android.material.chip.ChipGroup
android:id="@+id/tags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:chipSpacingHorizontal="4dp"/>
</LinearLayout>

Some files were not shown because too many files have changed in this diff Show More