mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-17 09:59:16 +01:00
Use Compose on Global/Migrate Search screen (#8631)
* Use Compose on Global/Migrate Search screen - Refactor to use Voyager and Compose - Use sealed class for state - Somethings are broken/missing due to screens using different navigation libraries * Review changes
This commit is contained in:
parent
ac1bed38f9
commit
f99b62a069
@ -10,3 +10,13 @@ data class MangaCover(
|
||||
val url: String?,
|
||||
val lastModified: Long,
|
||||
)
|
||||
|
||||
fun Manga.asMangaCover(): MangaCover {
|
||||
return MangaCover(
|
||||
mangaId = id,
|
||||
sourceId = source,
|
||||
isMangaFavorite = favorite,
|
||||
url = thumbnailUrl,
|
||||
lastModified = coverLastModified,
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,13 @@
|
||||
package eu.kanade.presentation.browse
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.kanade.presentation.components.Badge
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun InLibraryBadge(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
Badge(text = stringResource(R.string.in_library))
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package eu.kanade.presentation.browse
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchCardRow
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
|
||||
import eu.kanade.presentation.components.LazyColumn
|
||||
import eu.kanade.presentation.components.Scaffold
|
||||
import eu.kanade.presentation.util.padding
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchState
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchScreen(
|
||||
state: GlobalSearchState,
|
||||
navigateUp: () -> Unit,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
getManga: @Composable (CatalogueSource, Manga) -> State<Manga>,
|
||||
onClickSource: (CatalogueSource) -> Unit,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onLongClickItem: (Manga) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
GlobalSearchToolbar(
|
||||
searchQuery = state.searchQuery,
|
||||
progress = state.progress,
|
||||
total = state.total,
|
||||
navigateUp = navigateUp,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
onSearch = onSearch,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
GlobalSearchContent(
|
||||
items = state.items,
|
||||
contentPadding = paddingValues,
|
||||
getManga = getManga,
|
||||
onClickSource = onClickSource,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchContent(
|
||||
items: Map<CatalogueSource, GlobalSearchItemResult>,
|
||||
contentPadding: PaddingValues,
|
||||
getManga: @Composable (CatalogueSource, Manga) -> State<Manga>,
|
||||
onClickSource: (CatalogueSource) -> Unit,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onLongClickItem: (Manga) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items.forEach { (source, result) ->
|
||||
item {
|
||||
GlobalSearchResultItem(
|
||||
title = source.name,
|
||||
subtitle = LocaleHelper.getDisplayName(source.lang),
|
||||
onClick = { onClickSource(source) },
|
||||
) {
|
||||
when (result) {
|
||||
is GlobalSearchItemResult.Error -> {
|
||||
GlobalSearchErrorResultItem(message = result.throwable.message)
|
||||
}
|
||||
GlobalSearchItemResult.Loading -> {
|
||||
GlobalSearchLoadingResultItem()
|
||||
}
|
||||
is GlobalSearchItemResult.Success -> {
|
||||
if (result.isEmpty) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_results_found),
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
vertical = MaterialTheme.padding.small,
|
||||
),
|
||||
)
|
||||
return@GlobalSearchResultItem
|
||||
}
|
||||
|
||||
GlobalSearchCardRow(
|
||||
titles = result.result,
|
||||
getManga = { getManga(source, it) },
|
||||
onClick = onClickItem,
|
||||
onLongClick = onLongClickItem,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
package eu.kanade.presentation.browse
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchCardRow
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchEmptyResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchResultItem
|
||||
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
|
||||
import eu.kanade.presentation.components.LazyColumn
|
||||
import eu.kanade.presentation.components.Scaffold
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchState
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
|
||||
@Composable
|
||||
fun MigrateSearchScreen(
|
||||
navigateUp: () -> Unit,
|
||||
state: MigrateSearchState,
|
||||
getManga: @Composable (CatalogueSource, Manga) -> State<Manga>,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onClickSource: (CatalogueSource) -> Unit,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onLongClickItem: (Manga) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
GlobalSearchToolbar(
|
||||
searchQuery = state.searchQuery,
|
||||
progress = state.progress,
|
||||
total = state.total,
|
||||
navigateUp = navigateUp,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
onSearch = onSearch,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
MigrateSearchContent(
|
||||
sourceId = state.manga?.source ?: -1,
|
||||
items = state.items,
|
||||
contentPadding = paddingValues,
|
||||
getManga = getManga,
|
||||
onClickSource = onClickSource,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrateSearchContent(
|
||||
sourceId: Long,
|
||||
items: Map<CatalogueSource, GlobalSearchItemResult>,
|
||||
contentPadding: PaddingValues,
|
||||
getManga: @Composable (CatalogueSource, Manga) -> State<Manga>,
|
||||
onClickSource: (CatalogueSource) -> Unit,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onLongClickItem: (Manga) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items.forEach { (source, result) ->
|
||||
item {
|
||||
GlobalSearchResultItem(
|
||||
title = if (source.id == sourceId) "▶ ${source.name}" else source.name,
|
||||
subtitle = LocaleHelper.getDisplayName(source.lang),
|
||||
onClick = { onClickSource(source) },
|
||||
) {
|
||||
when (result) {
|
||||
is GlobalSearchItemResult.Error -> {
|
||||
GlobalSearchErrorResultItem(message = result.throwable.message)
|
||||
}
|
||||
GlobalSearchItemResult.Loading -> {
|
||||
GlobalSearchLoadingResultItem()
|
||||
}
|
||||
is GlobalSearchItemResult.Success -> {
|
||||
if (result.isEmpty) {
|
||||
GlobalSearchEmptyResultItem()
|
||||
return@GlobalSearchResultItem
|
||||
}
|
||||
|
||||
GlobalSearchCardRow(
|
||||
titles = result.result,
|
||||
getManga = { getManga(source, it) },
|
||||
onClick = onClickItem,
|
||||
onLongClick = onLongClickItem,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.MangaCover
|
||||
import eu.kanade.presentation.components.Badge
|
||||
import eu.kanade.presentation.browse.InLibraryBadge
|
||||
import eu.kanade.presentation.components.CommonMangaItemDefaults
|
||||
import eu.kanade.presentation.components.MangaComfortableGridItem
|
||||
import eu.kanade.presentation.util.plus
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun BrowseSourceComfortableGrid(
|
||||
@ -76,9 +74,7 @@ fun BrowseSourceComfortableGridItem(
|
||||
),
|
||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||
coverBadgeStart = {
|
||||
if (manga.favorite) {
|
||||
Badge(text = stringResource(R.string.in_library))
|
||||
}
|
||||
InLibraryBadge(enabled = manga.favorite)
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
|
@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.MangaCover
|
||||
import eu.kanade.presentation.components.Badge
|
||||
import eu.kanade.presentation.browse.InLibraryBadge
|
||||
import eu.kanade.presentation.components.CommonMangaItemDefaults
|
||||
import eu.kanade.presentation.components.MangaCompactGridItem
|
||||
import eu.kanade.presentation.util.plus
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun BrowseSourceCompactGrid(
|
||||
@ -76,9 +74,7 @@ private fun BrowseSourceCompactGridItem(
|
||||
),
|
||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||
coverBadgeStart = {
|
||||
if (manga.favorite) {
|
||||
Badge(text = stringResource(R.string.in_library))
|
||||
}
|
||||
InLibraryBadge(enabled = manga.favorite)
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
|
@ -4,19 +4,17 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.items
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.MangaCover
|
||||
import eu.kanade.presentation.components.Badge
|
||||
import eu.kanade.presentation.browse.InLibraryBadge
|
||||
import eu.kanade.presentation.components.CommonMangaItemDefaults
|
||||
import eu.kanade.presentation.components.LazyColumn
|
||||
import eu.kanade.presentation.components.MangaListItem
|
||||
import eu.kanade.presentation.util.plus
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun BrowseSourceList(
|
||||
@ -70,9 +68,7 @@ fun BrowseSourceListItem(
|
||||
),
|
||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||
badge = {
|
||||
if (manga.favorite) {
|
||||
Badge(text = stringResource(R.string.in_library))
|
||||
}
|
||||
InLibraryBadge(enabled = manga.favorite)
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
|
@ -0,0 +1,40 @@
|
||||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.asMangaCover
|
||||
import eu.kanade.presentation.util.padding
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchCardRow(
|
||||
titles: List<Manga>,
|
||||
getManga: @Composable (Manga) -> State<Manga>,
|
||||
onClick: (Manga) -> Unit,
|
||||
onLongClick: (Manga) -> Unit,
|
||||
) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
vertical = MaterialTheme.padding.small,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
) {
|
||||
items(titles) { title ->
|
||||
val title by getManga(title)
|
||||
GlobalSearchCard(
|
||||
title = title.title,
|
||||
cover = title.asMangaCover(),
|
||||
isFavorite = title.favorite,
|
||||
onClick = { onClick(title) },
|
||||
onLongClick = { onLongClick(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForward
|
||||
import androidx.compose.material.icons.outlined.Error
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.padding
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchResultItem(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = MaterialTheme.padding.medium,
|
||||
end = MaterialTheme.padding.tiny,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(text = subtitle)
|
||||
}
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(imageVector = Icons.Outlined.ArrowForward, contentDescription = null)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchEmptyResultItem() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_results_found),
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
vertical = MaterialTheme.padding.small,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchLoadingResultItem() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = MaterialTheme.padding.medium),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.Center),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchErrorResultItem(message: String?) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = MaterialTheme.padding.medium)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Outlined.Error, contentDescription = null)
|
||||
Text(text = message ?: stringResource(id = R.string.unknown_error))
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import eu.kanade.presentation.components.SearchToolbar
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchToolbar(
|
||||
searchQuery: String?,
|
||||
progress: Int,
|
||||
total: Int,
|
||||
navigateUp: () -> Unit,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
) {
|
||||
Box {
|
||||
SearchToolbar(
|
||||
searchQuery = searchQuery,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
onSearch = onSearch,
|
||||
navigateUp = navigateUp,
|
||||
)
|
||||
if (progress in 1 until total) {
|
||||
LinearProgressIndicator(
|
||||
progress = progress / total.toFloat(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.domain.manga.model.MangaCover
|
||||
import eu.kanade.presentation.browse.InLibraryBadge
|
||||
import eu.kanade.presentation.components.CommonMangaItemDefaults
|
||||
import eu.kanade.presentation.components.MangaComfortableGridItem
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchCard(
|
||||
title: String,
|
||||
cover: MangaCover,
|
||||
isFavorite: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.width(128.dp)) {
|
||||
MangaComfortableGridItem(
|
||||
title = title,
|
||||
coverData = cover,
|
||||
coverBadgeStart = {
|
||||
InLibraryBadge(enabled = isFavorite)
|
||||
},
|
||||
coverAlpha = if (isFavorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
}
|
@ -18,6 +18,8 @@ class Padding {
|
||||
val medium = 16.dp
|
||||
|
||||
val small = 8.dp
|
||||
|
||||
val tiny = 4.dp
|
||||
}
|
||||
|
||||
val MaterialTheme.padding: Padding
|
||||
|
@ -13,8 +13,7 @@ import eu.kanade.presentation.browse.MigrateMangaScreen
|
||||
import eu.kanade.presentation.components.LoadingScreen
|
||||
import eu.kanade.presentation.util.LocalRouter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@ -41,12 +40,8 @@ data class MigrationMangaScreen(
|
||||
navigateUp = navigator::pop,
|
||||
title = state.source!!.name,
|
||||
state = state,
|
||||
onClickItem = {
|
||||
router.pushController(SearchController(it.id))
|
||||
},
|
||||
onClickCover = {
|
||||
navigator.push(MangaScreen(it.id))
|
||||
},
|
||||
onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
|
||||
onClickCover = { navigator.push(MangaScreen(it.id)) },
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
@ -0,0 +1,326 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.domain.category.interactor.GetCategories
|
||||
import eu.kanade.domain.category.interactor.SetMangaCategories
|
||||
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.chapter.interactor.UpdateChapter
|
||||
import eu.kanade.domain.chapter.model.toChapterUpdate
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.MangaUpdate
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.domain.track.interactor.GetTracks
|
||||
import eu.kanade.domain.track.interactor.InsertTrack
|
||||
import eu.kanade.presentation.browse.MigrateSearchScreen
|
||||
import eu.kanade.presentation.util.LocalRouter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.core.preference.Preference
|
||||
import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.Date
|
||||
|
||||
class MigrateSearchScreen(private val mangaId: Long) : Screen {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val router = LocalRouter.currentOrThrow
|
||||
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
MigrateSearchScreen(
|
||||
navigateUp = navigator::pop,
|
||||
state = state,
|
||||
getManga = { source, manga ->
|
||||
screenModel.getManga(source = source, initialManga = manga)
|
||||
},
|
||||
onChangeSearchQuery = screenModel::updateSearchQuery,
|
||||
onSearch = screenModel::search,
|
||||
onClickSource = {
|
||||
if (!screenModel.incognitoMode.get()) {
|
||||
screenModel.lastUsedSourceId.set(it.id)
|
||||
}
|
||||
router.pushController(SourceSearchController(state.manga, it, state.searchQuery))
|
||||
},
|
||||
onClickItem = { screenModel.setDialog(MigrateSearchDialog.Migrate(it)) },
|
||||
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
|
||||
)
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
null -> {}
|
||||
is MigrateSearchDialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = state.manga!!,
|
||||
newManga = dialog.manga,
|
||||
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
|
||||
onDismissRequest = { screenModel.setDialog(null) },
|
||||
onClickTitle = {
|
||||
navigator.push(MangaScreen(dialog.manga.id, true))
|
||||
},
|
||||
onPopScreen = {
|
||||
if (navigator.lastItem is MangaScreen) {
|
||||
val lastItem = navigator.lastItem
|
||||
navigator.popUntil { navigator.items.contains(lastItem) }
|
||||
navigator.push(MangaScreen(dialog.manga.id))
|
||||
} else {
|
||||
navigator.pop()
|
||||
router.pushController(MangaController(dialog.manga.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrateDialog(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
screenModel: MigrateDialogScreenModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onClickTitle: () -> Unit,
|
||||
onPopScreen: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) }
|
||||
val items = remember {
|
||||
MigrationFlags.titles(oldManga)
|
||||
.map { context.getString(it) }
|
||||
.toList()
|
||||
}
|
||||
val selected = remember {
|
||||
mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray())
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(id = R.string.migration_dialog_what_to_include))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
items.forEachIndexed { index, title ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = selected[index], onCheckedChange = { selected[index] = !selected[index] })
|
||||
Text(text = title)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row {
|
||||
TextButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
onClickTitle()
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.action_show_manga))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
scope.launchIO {
|
||||
screenModel.migrateManga(oldManga, newManga, false)
|
||||
launchUI {
|
||||
onPopScreen()
|
||||
}
|
||||
}
|
||||
},) {
|
||||
Text(text = stringResource(id = R.string.copy))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
scope.launchIO {
|
||||
val selectedIndices = mutableListOf<Int>()
|
||||
selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) }
|
||||
val newValue = MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray())
|
||||
screenModel.migrateFlags.set(newValue)
|
||||
screenModel.migrateManga(oldManga, newManga, true)
|
||||
launchUI {
|
||||
onPopScreen()
|
||||
}
|
||||
}
|
||||
},) {
|
||||
Text(text = stringResource(id = R.string.migrate))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
class MigrateDialogScreenModel(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val insertTrack: InsertTrack = Injekt.get(),
|
||||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val preferenceStore: PreferenceStore = Injekt.get(),
|
||||
) : ScreenModel {
|
||||
|
||||
val migrateFlags: Preference<Int> by lazy {
|
||||
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
|
||||
|
||||
suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) {
|
||||
val source = sourceManager.get(newManga.source) ?: return
|
||||
val prevSource = sourceManager.get(oldManga.source)
|
||||
|
||||
try {
|
||||
val chapters = source.getChapterList(newManga.toSManga())
|
||||
|
||||
migrateMangaInternal(
|
||||
oldSource = prevSource,
|
||||
newSource = source,
|
||||
oldManga = oldManga,
|
||||
newManga = newManga,
|
||||
sourceChapters = chapters,
|
||||
replace = replace,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateMangaInternal(
|
||||
oldSource: Source?,
|
||||
newSource: Source,
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
sourceChapters: List<SChapter>,
|
||||
replace: Boolean,
|
||||
) {
|
||||
val flags = migrateFlags.get()
|
||||
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateTracks = MigrationFlags.hasTracks(flags)
|
||||
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
|
||||
|
||||
try {
|
||||
syncChaptersWithSource.await(sourceChapters, newManga, newSource)
|
||||
} catch (e: Exception) {
|
||||
// Worst case, chapters won't be synced
|
||||
}
|
||||
|
||||
// Update chapters read, bookmark and dateFetch
|
||||
if (migrateChapters) {
|
||||
val prevMangaChapters = getChapterByMangaId.await(oldManga.id)
|
||||
val mangaChapters = getChapterByMangaId.await(newManga.id)
|
||||
|
||||
val maxChapterRead = prevMangaChapters
|
||||
.filter { it.read }
|
||||
.maxOfOrNull { it.chapterNumber }
|
||||
|
||||
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
|
||||
var updatedChapter = mangaChapter
|
||||
if (updatedChapter.isRecognizedNumber) {
|
||||
val prevChapter = prevMangaChapters
|
||||
.find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber }
|
||||
|
||||
if (prevChapter != null) {
|
||||
updatedChapter = updatedChapter.copy(
|
||||
dateFetch = prevChapter.dateFetch,
|
||||
bookmark = prevChapter.bookmark,
|
||||
)
|
||||
}
|
||||
|
||||
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
|
||||
updatedChapter = updatedChapter.copy(read = true)
|
||||
}
|
||||
}
|
||||
|
||||
updatedChapter
|
||||
}
|
||||
|
||||
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
|
||||
updateChapter.awaitAll(chapterUpdates)
|
||||
}
|
||||
|
||||
// Update categories
|
||||
if (migrateCategories) {
|
||||
val categoryIds = getCategories.await(oldManga.id).map { it.id }
|
||||
setMangaCategories.await(newManga.id, categoryIds)
|
||||
}
|
||||
|
||||
// Update track
|
||||
if (migrateTracks) {
|
||||
val tracks = getTracks.await(oldManga.id).mapNotNull { track ->
|
||||
val updatedTrack = track.copy(mangaId = newManga.id)
|
||||
|
||||
val service = enhancedServices
|
||||
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
|
||||
|
||||
if (service != null) {
|
||||
service.migrateTrack(updatedTrack, newManga, newSource)
|
||||
} else {
|
||||
updatedTrack
|
||||
}
|
||||
}
|
||||
insertTrack.awaitAll(tracks)
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
updateManga.await(MangaUpdate(oldManga.id, favorite = false, dateAdded = 0))
|
||||
}
|
||||
|
||||
// Update custom cover (recheck if custom cover exists)
|
||||
if (migrateCustomCover && oldManga.hasCustomCover()) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream())
|
||||
}
|
||||
|
||||
updateManga.await(
|
||||
MangaUpdate(
|
||||
id = newManga.id,
|
||||
favorite = true,
|
||||
chapterFlags = oldManga.chapterFlags,
|
||||
viewerFlags = oldManga.viewerFlags,
|
||||
dateAdded = if (replace) oldManga.dateAdded else Date().time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.coroutineScope
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.interactor.GetManga
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MigrateSearchScreenModel(
|
||||
val mangaId: Long,
|
||||
initialExtensionFilter: String = "",
|
||||
preferences: BasePreferences = Injekt.get(),
|
||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
) : SearchScreenModel<MigrateSearchState>(MigrateSearchState()) {
|
||||
|
||||
init {
|
||||
extensionFilter = initialExtensionFilter
|
||||
coroutineScope.launch {
|
||||
val manga = getManga.await(mangaId)!!
|
||||
|
||||
mutableState.update {
|
||||
it.copy(manga = manga, searchQuery = manga.title)
|
||||
}
|
||||
|
||||
search(manga.title)
|
||||
}
|
||||
}
|
||||
|
||||
val incognitoMode = preferences.incognitoMode()
|
||||
val lastUsedSourceId = sourcePreferences.lastUsedSource()
|
||||
|
||||
override fun getEnabledSources(): List<CatalogueSource> {
|
||||
val enabledLanguages = sourcePreferences.enabledLanguages().get()
|
||||
val disabledSources = sourcePreferences.disabledSources().get()
|
||||
val pinnedSources = sourcePreferences.pinnedSources().get()
|
||||
|
||||
return sourceManager.getCatalogueSources()
|
||||
.filter { it.lang in enabledLanguages }
|
||||
.filterNot { "${it.id}" in disabledSources }
|
||||
.sortedWith(compareBy({ "${it.id}" !in pinnedSources }, { "${it.name.lowercase()} (${it.lang})" }))
|
||||
.sortedByDescending { it.id == state.value.manga!!.id }
|
||||
}
|
||||
|
||||
override fun updateSearchQuery(query: String?) {
|
||||
mutableState.update {
|
||||
it.copy(searchQuery = query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateItems(items: Map<CatalogueSource, GlobalSearchItemResult>) {
|
||||
mutableState.update {
|
||||
it.copy(items = items)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItems(): Map<CatalogueSource, GlobalSearchItemResult> {
|
||||
return mutableState.value.items
|
||||
}
|
||||
|
||||
fun setDialog(dialog: MigrateSearchDialog?) {
|
||||
mutableState.update {
|
||||
it.copy(dialog = dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MigrateSearchDialog {
|
||||
data class Migrate(val manga: Manga) : MigrateSearchDialog()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class MigrateSearchState(
|
||||
val manga: Manga? = null,
|
||||
val searchQuery: String? = null,
|
||||
val items: Map<CatalogueSource, GlobalSearchItemResult> = emptyMap(),
|
||||
val dialog: MigrateSearchDialog? = null,
|
||||
) {
|
||||
|
||||
val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading }
|
||||
|
||||
val total: Int = items.size
|
||||
}
|
@ -1,154 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.isVisible
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import eu.kanade.domain.manga.interactor.GetManga
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableCompat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SearchController(
|
||||
private var manga: Manga? = null,
|
||||
) : GlobalSearchController(manga?.title) {
|
||||
|
||||
constructor(mangaId: Long) : this(
|
||||
runBlocking {
|
||||
Injekt.get<GetManga>()
|
||||
.await(mangaId)
|
||||
},
|
||||
)
|
||||
|
||||
private var newManga: Manga? = null
|
||||
|
||||
override fun createPresenter(): GlobalSearchPresenter {
|
||||
return SearchPresenter(
|
||||
initialQuery,
|
||||
manga!!,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putSerializable(::manga.name, manga)
|
||||
outState.putSerializable(::newManga.name, newManga)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
manga = savedInstanceState.getSerializableCompat(::manga.name)
|
||||
newManga = savedInstanceState.getSerializableCompat(::newManga.name)
|
||||
}
|
||||
|
||||
fun migrateManga(manga: Manga? = null, newManga: Manga?) {
|
||||
manga ?: return
|
||||
newManga ?: return
|
||||
|
||||
(presenter as? SearchPresenter)?.migrateManga(manga, newManga, true)
|
||||
}
|
||||
|
||||
fun copyManga(manga: Manga? = null, newManga: Manga?) {
|
||||
manga ?: return
|
||||
newManga ?: return
|
||||
|
||||
(presenter as? SearchPresenter)?.migrateManga(manga, newManga, false)
|
||||
}
|
||||
|
||||
override fun onMangaClick(manga: Manga) {
|
||||
newManga = manga
|
||||
val dialog =
|
||||
MigrationDialog(this.manga, newManga, this)
|
||||
dialog.targetController = this
|
||||
dialog.showDialog(router)
|
||||
}
|
||||
|
||||
override fun onMangaLongClick(manga: Manga) {
|
||||
// Call parent's default click listener
|
||||
super.onMangaClick(manga)
|
||||
}
|
||||
|
||||
fun renderIsReplacingManga(isReplacingManga: Boolean, newManga: Manga?) {
|
||||
binding.progress.isVisible = isReplacingManga
|
||||
if (!isReplacingManga) {
|
||||
router.popController(this)
|
||||
if (newManga?.id != null) {
|
||||
val newMangaController = RouterTransaction.with(MangaController(newManga.id))
|
||||
if (router.backstack.lastOrNull()?.controller is MangaController) {
|
||||
// Replace old MangaController
|
||||
router.replaceTopController(newMangaController)
|
||||
} else {
|
||||
// Push MangaController on top of MigrationController
|
||||
router.pushController(newMangaController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MigrationDialog(private val manga: Manga? = null, private val newManga: Manga? = null, private val callingController: Controller? = null) : DialogController() {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
val migrateFlags = ((targetController as SearchController).presenter as SearchPresenter).migrateFlags
|
||||
val prefValue = migrateFlags.get()
|
||||
val enabledFlagsPositions = MigrationFlags.getEnabledFlagsPositions(prefValue)
|
||||
val items = MigrationFlags.titles(manga)
|
||||
.map { resources?.getString(it) }
|
||||
.toTypedArray()
|
||||
val selected = items
|
||||
.mapIndexed { i, _ -> enabledFlagsPositions.contains(i) }
|
||||
.toBooleanArray()
|
||||
|
||||
return MaterialAlertDialogBuilder(activity!!)
|
||||
.setTitle(R.string.migration_dialog_what_to_include)
|
||||
.setMultiChoiceItems(items, selected) { _, which, checked ->
|
||||
selected[which] = checked
|
||||
}
|
||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||
// Save current settings for the next time
|
||||
val selectedIndices = mutableListOf<Int>()
|
||||
selected.forEachIndexed { i, b -> if (b) selectedIndices.add(i) }
|
||||
val newValue = MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray())
|
||||
migrateFlags.set(newValue)
|
||||
|
||||
if (callingController != null) {
|
||||
if (callingController.javaClass == SourceSearchController::class.java) {
|
||||
router.popController(callingController)
|
||||
}
|
||||
}
|
||||
(targetController as? SearchController)?.migrateManga(manga, newManga)
|
||||
}
|
||||
.setNegativeButton(R.string.copy) { _, _ ->
|
||||
if (callingController != null) {
|
||||
if (callingController.javaClass == SourceSearchController::class.java) {
|
||||
router.popController(callingController)
|
||||
}
|
||||
}
|
||||
(targetController as? SearchController)?.copyManga(manga, newManga)
|
||||
}
|
||||
.setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
|
||||
dismissDialog()
|
||||
router.pushController(MangaController(newManga!!.id))
|
||||
}
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleClick(source: CatalogueSource) {
|
||||
presenter.sourcePreferences.lastUsedSource().set(source.id)
|
||||
|
||||
router.pushController(SourceSearchController(manga, source, presenter.query))
|
||||
}
|
||||
}
|
@ -1,204 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import android.os.Bundle
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.domain.category.interactor.GetCategories
|
||||
import eu.kanade.domain.category.interactor.SetMangaCategories
|
||||
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.chapter.interactor.UpdateChapter
|
||||
import eu.kanade.domain.chapter.model.toChapterUpdate
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.MangaUpdate
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.domain.track.interactor.GetTracks
|
||||
import eu.kanade.domain.track.interactor.InsertTrack
|
||||
import eu.kanade.tachiyomi.core.preference.Preference
|
||||
import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchCardItem
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
|
||||
class SearchPresenter(
|
||||
initialQuery: String? = "",
|
||||
private val manga: Manga,
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val insertTrack: InsertTrack = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
preferenceStore: PreferenceStore = Injekt.get(),
|
||||
) : GlobalSearchPresenter(initialQuery) {
|
||||
|
||||
private val replacingMangaRelay = BehaviorRelay.create<Pair<Boolean, Manga?>>()
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
|
||||
|
||||
val migrateFlags: Preference<Int> by lazy {
|
||||
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
replacingMangaRelay.subscribeLatestCache(
|
||||
{ controller, (isReplacingManga, newManga) ->
|
||||
(controller as? SearchController)?.renderIsReplacingManga(isReplacingManga, newManga)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun getEnabledSources(): List<CatalogueSource> {
|
||||
// Put the source of the selected manga at the top
|
||||
return super.getEnabledSources()
|
||||
.sortedByDescending { it.id == manga.source }
|
||||
}
|
||||
|
||||
override fun createCatalogueSearchItem(source: CatalogueSource, results: List<GlobalSearchCardItem>?): GlobalSearchItem {
|
||||
// Set the catalogue search item as highlighted if the source matches that of the selected manga
|
||||
return GlobalSearchItem(source, results, source.id == manga.source)
|
||||
}
|
||||
|
||||
override suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
|
||||
val localManga = super.networkToLocalManga(sManga, sourceId)
|
||||
// For migration, displayed title should always match source rather than local DB
|
||||
return localManga.copy(title = sManga.title)
|
||||
}
|
||||
|
||||
fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) {
|
||||
val source = sourceManager.get(manga.source) ?: return
|
||||
val prevSource = sourceManager.get(prevManga.source)
|
||||
|
||||
replacingMangaRelay.call(Pair(true, null))
|
||||
|
||||
presenterScope.launchIO {
|
||||
try {
|
||||
val chapters = source.getChapterList(manga.toSManga())
|
||||
|
||||
migrateMangaInternal(prevSource, source, chapters, prevManga, manga, replace)
|
||||
} catch (e: Throwable) {
|
||||
withUIContext { view?.applicationContext?.toast(e.message) }
|
||||
}
|
||||
|
||||
withUIContext { replacingMangaRelay.call(Pair(false, manga)) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateMangaInternal(
|
||||
prevSource: Source?,
|
||||
source: Source,
|
||||
sourceChapters: List<SChapter>,
|
||||
prevManga: Manga,
|
||||
manga: Manga,
|
||||
replace: Boolean,
|
||||
) {
|
||||
val flags = migrateFlags.get()
|
||||
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateTracks = MigrationFlags.hasTracks(flags)
|
||||
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
|
||||
|
||||
try {
|
||||
syncChaptersWithSource.await(sourceChapters, manga, source)
|
||||
} catch (e: Exception) {
|
||||
// Worst case, chapters won't be synced
|
||||
}
|
||||
|
||||
// Update chapters read, bookmark and dateFetch
|
||||
if (migrateChapters) {
|
||||
val prevMangaChapters = getChapterByMangaId.await(prevManga.id)
|
||||
val mangaChapters = getChapterByMangaId.await(manga.id)
|
||||
|
||||
val maxChapterRead = prevMangaChapters
|
||||
.filter { it.read }
|
||||
.maxOfOrNull { it.chapterNumber }
|
||||
|
||||
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
|
||||
var updatedChapter = mangaChapter
|
||||
if (updatedChapter.isRecognizedNumber) {
|
||||
val prevChapter = prevMangaChapters
|
||||
.find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber }
|
||||
|
||||
if (prevChapter != null) {
|
||||
updatedChapter = updatedChapter.copy(
|
||||
dateFetch = prevChapter.dateFetch,
|
||||
bookmark = prevChapter.bookmark,
|
||||
)
|
||||
}
|
||||
|
||||
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
|
||||
updatedChapter = updatedChapter.copy(read = true)
|
||||
}
|
||||
}
|
||||
|
||||
updatedChapter
|
||||
}
|
||||
|
||||
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
|
||||
updateChapter.awaitAll(chapterUpdates)
|
||||
}
|
||||
|
||||
// Update categories
|
||||
if (migrateCategories) {
|
||||
val categoryIds = getCategories.await(prevManga.id).map { it.id }
|
||||
setMangaCategories.await(manga.id, categoryIds)
|
||||
}
|
||||
|
||||
// Update track
|
||||
if (migrateTracks) {
|
||||
val tracks = getTracks.await(prevManga.id).mapNotNull { track ->
|
||||
val updatedTrack = track.copy(mangaId = manga.id)
|
||||
|
||||
val service = enhancedServices
|
||||
.firstOrNull { it.isTrackFrom(updatedTrack, prevManga, prevSource) }
|
||||
|
||||
if (service != null) {
|
||||
service.migrateTrack(updatedTrack, manga, source)
|
||||
} else {
|
||||
updatedTrack
|
||||
}
|
||||
}
|
||||
insertTrack.awaitAll(tracks)
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
updateManga.await(MangaUpdate(prevManga.id, favorite = false, dateAdded = 0))
|
||||
}
|
||||
|
||||
// Update custom cover (recheck if custom cover exists)
|
||||
if (migrateCustomCover && prevManga.hasCustomCover()) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
coverCache.setCustomCoverToCache(manga, coverCache.getCustomCoverFile(prevManga.id).inputStream())
|
||||
}
|
||||
|
||||
updateManga.await(
|
||||
MangaUpdate(
|
||||
id = manga.id,
|
||||
favorite = true,
|
||||
chapterFlags = prevManga.chapterFlags,
|
||||
viewerFlags = prevManga.viewerFlags,
|
||||
dateAdded = if (replace) prevManga.dateAdded else Date().time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
@ -3,12 +3,19 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.os.bundleOf
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.presentation.browse.SourceSearchScreen
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.setRoot
|
||||
import eu.kanade.tachiyomi.ui.browse.BrowseController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableCompat
|
||||
|
||||
@ -25,7 +32,6 @@ class SourceSearchController(
|
||||
)
|
||||
|
||||
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
|
||||
private var newManga: Manga? = null
|
||||
|
||||
@Composable
|
||||
override fun ComposeContent() {
|
||||
@ -34,11 +40,7 @@ class SourceSearchController(
|
||||
navigateUp = { router.popCurrentController() },
|
||||
onFabClick = { filterSheet?.show() },
|
||||
onMangaClick = {
|
||||
newManga = it
|
||||
val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController?
|
||||
val dialog = SearchController.MigrationDialog(oldManga, newManga, this)
|
||||
dialog.targetController = searchController
|
||||
dialog.showDialog(router)
|
||||
presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it)
|
||||
},
|
||||
onWebViewClick = f@{
|
||||
val source = presenter.source as? HttpSource ?: return@f
|
||||
@ -49,6 +51,25 @@ class SourceSearchController(
|
||||
},
|
||||
)
|
||||
|
||||
when (val dialog = presenter.dialog) {
|
||||
is BrowseSourcePresenter.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = oldManga!!,
|
||||
newManga = dialog.newManga,
|
||||
// TODO: Move screen model down into Dialog when this screen is using Voyager
|
||||
screenModel = remember { MigrateDialogScreenModel() },
|
||||
onDismissRequest = { presenter.dialog = null },
|
||||
onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
|
||||
onPopScreen = {
|
||||
// TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
|
||||
router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
|
||||
router.pushController(MangaController(dialog.newManga.id))
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
LaunchedEffect(presenter.filters) {
|
||||
initFilterSheet()
|
||||
}
|
||||
|
@ -81,6 +81,8 @@ open class BrowseSourceController(bundle: Bundle) :
|
||||
|
||||
val onDismissRequest = { presenter.dialog = null }
|
||||
when (val dialog = presenter.dialog) {
|
||||
null -> {}
|
||||
is Dialog.Migrate -> {}
|
||||
is Dialog.AddDuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@ -111,7 +113,6 @@ open class BrowseSourceController(bundle: Bundle) :
|
||||
},
|
||||
)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::navigateUp)
|
||||
|
@ -334,6 +334,7 @@ open class BrowseSourcePresenter(
|
||||
val manga: Manga,
|
||||
val initialSelection: List<CheckboxState.State<Category>>,
|
||||
) : Dialog()
|
||||
data class Migrate(val newManga: Manga) : Dialog()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,79 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.SparseArray
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
|
||||
/**
|
||||
* Adapter that holds the search cards.
|
||||
*
|
||||
* @param controller instance of [GlobalSearchController].
|
||||
*/
|
||||
class GlobalSearchAdapter(val controller: GlobalSearchController) :
|
||||
FlexibleAdapter<GlobalSearchItem>(null, controller, true) {
|
||||
|
||||
val titleClickListener: OnTitleClickListener = controller
|
||||
|
||||
/**
|
||||
* Bundle where the view state of the holders is saved.
|
||||
*/
|
||||
private var bundle = Bundle()
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
restoreHolderState(holder)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
saveHolderState(holder, bundle)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
val holdersBundle = Bundle()
|
||||
allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) }
|
||||
outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the view state of the given holder.
|
||||
*
|
||||
* @param holder The holder to save.
|
||||
* @param outState The bundle where the state is saved.
|
||||
*/
|
||||
private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) {
|
||||
val key = "holder_${holder.bindingAdapterPosition}"
|
||||
val holderState = SparseArray<Parcelable>()
|
||||
holder.itemView.saveHierarchyState(holderState)
|
||||
outState.putSparseParcelableArray(key, holderState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the view state of the given holder.
|
||||
*
|
||||
* @param holder The holder to restore.
|
||||
*/
|
||||
private fun restoreHolderState(holder: RecyclerView.ViewHolder) {
|
||||
val key = "holder_${holder.bindingAdapterPosition}"
|
||||
val holderState = bundle.getSparseParcelableArray<Parcelable>(key)
|
||||
if (holderState != null) {
|
||||
holder.itemView.restoreHierarchyState(holderState)
|
||||
bundle.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
interface OnTitleClickListener {
|
||||
fun onTitleClick(source: CatalogueSource)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HOLDER_BUNDLE_KEY = "holder_bundle"
|
@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
|
||||
/**
|
||||
* Adapter that holds the manga items from search results.
|
||||
*
|
||||
* @param controller instance of [GlobalSearchController].
|
||||
*/
|
||||
class GlobalSearchCardAdapter(controller: GlobalSearchController) :
|
||||
FlexibleAdapter<GlobalSearchCardItem>(null, controller, true) {
|
||||
|
||||
/**
|
||||
* Listen for browse item clicks.
|
||||
*/
|
||||
val mangaClickListener: OnMangaClickListener = controller
|
||||
|
||||
/**
|
||||
* Listener which should be called when user clicks browse.
|
||||
* Note: Should only be handled by [GlobalSearchController]
|
||||
*/
|
||||
interface OnMangaClickListener {
|
||||
fun onMangaClick(manga: Manga)
|
||||
fun onMangaLongClick(manga: Manga)
|
||||
}
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import coil.dispose
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
|
||||
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding
|
||||
import eu.kanade.tachiyomi.util.view.loadAutoPause
|
||||
|
||||
class GlobalSearchCardHolder(view: View, adapter: GlobalSearchCardAdapter) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
|
||||
private val binding = GlobalSearchControllerCardItemBinding.bind(view)
|
||||
|
||||
init {
|
||||
// Call onMangaClickListener when item is pressed.
|
||||
itemView.setOnClickListener {
|
||||
val item = adapter.getItem(bindingAdapterPosition)
|
||||
if (item != null) {
|
||||
adapter.mangaClickListener.onMangaClick(item.manga)
|
||||
}
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
val item = adapter.getItem(bindingAdapterPosition)
|
||||
if (item != null) {
|
||||
adapter.mangaClickListener.onMangaLongClick(item.manga)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(manga: Manga) {
|
||||
binding.card.clipToOutline = true
|
||||
|
||||
// Set manga title
|
||||
binding.title.text = manga.title
|
||||
|
||||
// Set alpha of thumbnail.
|
||||
binding.cover.alpha = if (manga.favorite) 0.3f else 1.0f
|
||||
|
||||
// For rounded corners
|
||||
binding.badges.clipToOutline = true
|
||||
|
||||
// Set favorite badge
|
||||
binding.favoriteText.isVisible = manga.favorite
|
||||
|
||||
setImage(manga)
|
||||
}
|
||||
|
||||
fun setImage(manga: Manga) {
|
||||
binding.cover.dispose()
|
||||
binding.cover.loadAutoPause(manga) {
|
||||
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
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.domain.manga.model.Manga
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
class GlobalSearchCardItem(val manga: Manga) : AbstractFlexibleItem<GlobalSearchCardHolder>() {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.global_search_controller_card_item
|
||||
}
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): GlobalSearchCardHolder {
|
||||
return GlobalSearchCardHolder(view, adapter as GlobalSearchCardAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: GlobalSearchCardHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?,
|
||||
) {
|
||||
holder.bind(manga)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is GlobalSearchCardItem) {
|
||||
return manga.id == other.manga.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return manga.id.hashCode()
|
||||
}
|
||||
}
|
@ -1,226 +1,25 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import dev.chrisbanes.insetter.applyInsetter
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import eu.kanade.presentation.util.LocalRouter
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
||||
|
||||
/**
|
||||
* This controller shows and manages the different search result in global search.
|
||||
* This controller should only handle UI actions, IO actions should be done by [GlobalSearchPresenter]
|
||||
* [GlobalSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search
|
||||
*/
|
||||
open class GlobalSearchController(
|
||||
protected val initialQuery: String? = null,
|
||||
private val extensionFilter: String? = null,
|
||||
) : SearchableNucleusController<GlobalSearchControllerBinding, GlobalSearchPresenter>(),
|
||||
GlobalSearchCardAdapter.OnMangaClickListener,
|
||||
GlobalSearchAdapter.OnTitleClickListener {
|
||||
class GlobalSearchController(
|
||||
val searchQuery: String = "",
|
||||
val extensionFilter: String = "",
|
||||
) : BasicFullComposeController() {
|
||||
|
||||
private val preferences: BasePreferences by injectLazy()
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
|
||||
/**
|
||||
* Adapter containing search results grouped by lang.
|
||||
*/
|
||||
protected var adapter: GlobalSearchAdapter? = null
|
||||
|
||||
/**
|
||||
* Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu
|
||||
*/
|
||||
private var optionsMenuSearchItem: MenuItem? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun createBinding(inflater: LayoutInflater) = GlobalSearchControllerBinding.inflate(inflater)
|
||||
|
||||
override fun getTitle(): String? {
|
||||
return presenter.query
|
||||
}
|
||||
|
||||
override fun createPresenter(): GlobalSearchPresenter {
|
||||
return GlobalSearchPresenter(initialQuery, extensionFilter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when manga in global search is clicked, opens manga.
|
||||
*
|
||||
* @param manga clicked item containing manga information.
|
||||
*/
|
||||
override fun onMangaClick(manga: Manga) {
|
||||
router.pushController(MangaController(manga.id, true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when manga in global search is long clicked.
|
||||
*
|
||||
* @param manga clicked item containing manga information.
|
||||
*/
|
||||
override fun onMangaLongClick(manga: Manga) {
|
||||
// Delegate to single click by default.
|
||||
onMangaClick(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds items to the options menu.
|
||||
*
|
||||
* @param menu menu containing options.
|
||||
* @param inflater used to load the menu xml.
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
createOptionsMenu(
|
||||
menu,
|
||||
inflater,
|
||||
R.menu.global_search,
|
||||
R.id.action_search,
|
||||
)
|
||||
|
||||
optionsMenuSearchItem = menu.findItem(R.id.action_search)
|
||||
|
||||
// Focus search on launch from browse screen
|
||||
if (initialQuery.isNullOrEmpty()) {
|
||||
optionsMenuSearchItem?.expandActionView()
|
||||
@Composable
|
||||
override fun ComposeContent() {
|
||||
CompositionLocalProvider(LocalRouter provides router) {
|
||||
Navigator(
|
||||
screen = GlobalSearchScreen(
|
||||
searchQuery = searchQuery,
|
||||
extensionFilter = extensionFilter,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearchMenuItemActionCollapse(item: MenuItem?) {
|
||||
super.onSearchMenuItemActionCollapse(item)
|
||||
// Close this screen if query is empty
|
||||
// i.e. launch from browse screen and clicking the back button icon without making any search
|
||||
if (presenter.query.isEmpty()) {
|
||||
router.popCurrentController()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearchMenuItemActionExpand(item: MenuItem?) {
|
||||
super.onSearchMenuItemActionExpand(item)
|
||||
val searchView = optionsMenuSearchItem?.actionView as SearchView
|
||||
searchView.onActionViewExpanded() // Required to show the query in the view
|
||||
|
||||
if (nonSubmittedQuery.isBlank()) {
|
||||
searchView.setQuery(presenter.query, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearchViewQueryTextSubmit(query: String?) {
|
||||
presenter.search(query ?: "")
|
||||
optionsMenuSearchItem?.collapseActionView()
|
||||
setTitle() // Update toolbar title
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is created
|
||||
*
|
||||
* @param view view of controller
|
||||
*/
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
binding.recycler.applyInsetter {
|
||||
type(navigationBars = true) {
|
||||
padding()
|
||||
}
|
||||
}
|
||||
|
||||
adapter = GlobalSearchAdapter(this)
|
||||
|
||||
// Create recycler and set adapter.
|
||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recycler.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
adapter = null
|
||||
super.onDestroyView(view)
|
||||
}
|
||||
|
||||
override fun onSaveViewState(view: View, outState: Bundle) {
|
||||
super.onSaveViewState(view, outState)
|
||||
adapter?.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreViewState(view: View, savedViewState: Bundle) {
|
||||
super.onRestoreViewState(view, savedViewState)
|
||||
adapter?.onRestoreInstanceState(savedViewState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view holder for the given manga.
|
||||
*
|
||||
* @param source used to find holder containing source
|
||||
* @return the holder of the manga or null if it's not bound.
|
||||
*/
|
||||
private fun getHolder(source: CatalogueSource): GlobalSearchHolder? {
|
||||
val adapter = adapter ?: return null
|
||||
|
||||
adapter.allBoundViewHolders.forEach { holder ->
|
||||
val item = adapter.getItem(holder.bindingAdapterPosition)
|
||||
if (item != null && source.id == item.source.id) {
|
||||
return holder as GlobalSearchHolder
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Add search result to adapter.
|
||||
*
|
||||
* @param searchResult result of search.
|
||||
*/
|
||||
fun setItems(searchResult: List<GlobalSearchItem>) {
|
||||
if (searchResult.isEmpty() && sourcePreferences.searchPinnedSourcesOnly().get()) {
|
||||
binding.emptyView.show(R.string.no_pinned_sources)
|
||||
} else {
|
||||
binding.emptyView.hide()
|
||||
}
|
||||
|
||||
adapter?.updateDataSet(searchResult)
|
||||
|
||||
val progress = searchResult.mapNotNull { it.results }.size.toDouble() / searchResult.size
|
||||
if (progress < 1) {
|
||||
binding.progressBar.isVisible = true
|
||||
binding.progressBar.progress = (progress * 100).toInt()
|
||||
} else {
|
||||
binding.progressBar.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when a manga is initialized.
|
||||
*
|
||||
* @param manga the initialized manga.
|
||||
*/
|
||||
fun onMangaInitialized(source: CatalogueSource, manga: Manga) {
|
||||
getHolder(source)?.setImage(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a catalogue with the given search.
|
||||
*/
|
||||
override fun onTitleClick(source: CatalogueSource) {
|
||||
if (!preferences.incognitoMode().get()) {
|
||||
sourcePreferences.lastUsedSource().set(source.id)
|
||||
}
|
||||
router.pushController(BrowseSourceController(source, presenter.query))
|
||||
}
|
||||
}
|
||||
|
@ -1,110 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
|
||||
/**
|
||||
* Holder that binds the [GlobalSearchItem] containing catalogue cards.
|
||||
*
|
||||
* @param view view of [GlobalSearchItem]
|
||||
* @param adapter instance of [GlobalSearchAdapter]
|
||||
*/
|
||||
class GlobalSearchHolder(view: View, val adapter: GlobalSearchAdapter) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
|
||||
private val binding = GlobalSearchControllerCardBinding.bind(view)
|
||||
|
||||
/**
|
||||
* Adapter containing manga from search results.
|
||||
*/
|
||||
private val mangaAdapter = GlobalSearchCardAdapter(adapter.controller)
|
||||
|
||||
private var lastBoundResults: List<GlobalSearchCardItem>? = null
|
||||
|
||||
init {
|
||||
// Set layout horizontal.
|
||||
binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.recycler.adapter = mangaAdapter
|
||||
|
||||
binding.titleWrapper.setOnClickListener {
|
||||
adapter.getItem(bindingAdapterPosition)?.let {
|
||||
adapter.titleClickListener.onTitleClick(it.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the loading of source search result.
|
||||
*
|
||||
* @param item item of card.
|
||||
*/
|
||||
fun bind(item: GlobalSearchItem) {
|
||||
val source = item.source
|
||||
val results = item.results
|
||||
|
||||
val titlePrefix = if (item.highlighted) "▶ " else ""
|
||||
|
||||
binding.title.text = titlePrefix + source.name
|
||||
binding.subtitle.isVisible = source !is LocalSource
|
||||
binding.subtitle.text = LocaleHelper.getDisplayName(source.lang)
|
||||
|
||||
when {
|
||||
results == null -> {
|
||||
binding.progress.isVisible = true
|
||||
showResultsHolder()
|
||||
}
|
||||
results.isEmpty() -> {
|
||||
binding.progress.isVisible = false
|
||||
showNoResults()
|
||||
}
|
||||
else -> {
|
||||
binding.progress.isVisible = false
|
||||
showResultsHolder()
|
||||
}
|
||||
}
|
||||
if (results !== lastBoundResults) {
|
||||
mangaAdapter.updateDataSet(results)
|
||||
lastBoundResults = results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when a manga is initialized.
|
||||
*
|
||||
* @param manga the initialized manga.
|
||||
*/
|
||||
fun setImage(manga: Manga) {
|
||||
getHolder(manga)?.setImage(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view holder for the given manga.
|
||||
*
|
||||
* @param manga the manga to find.
|
||||
* @return the holder of the manga or null if it's not bound.
|
||||
*/
|
||||
private fun getHolder(manga: Manga): GlobalSearchCardHolder? {
|
||||
mangaAdapter.allBoundViewHolders.forEach { holder ->
|
||||
val item = mangaAdapter.getItem(holder.bindingAdapterPosition)
|
||||
if (item != null && item.manga.id == manga.id) {
|
||||
return holder as GlobalSearchCardHolder
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showResultsHolder() {
|
||||
binding.noResultsFound.isVisible = false
|
||||
}
|
||||
|
||||
private fun showNoResults() {
|
||||
binding.noResultsFound.isVisible = true
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
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 eu.kanade.tachiyomi.source.CatalogueSource
|
||||
|
||||
/**
|
||||
* Item that contains search result information.
|
||||
*
|
||||
* @param source the source for the search results.
|
||||
* @param results the search results.
|
||||
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
|
||||
*/
|
||||
class GlobalSearchItem(val source: CatalogueSource, val results: List<GlobalSearchCardItem>?, val highlighted: Boolean = false) :
|
||||
AbstractFlexibleItem<GlobalSearchHolder>() {
|
||||
|
||||
/**
|
||||
* Set view.
|
||||
*
|
||||
* @return id of view
|
||||
*/
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.global_search_controller_card
|
||||
}
|
||||
|
||||
/**
|
||||
* Create view holder (see [GlobalSearchAdapter].
|
||||
*
|
||||
* @return holder of view.
|
||||
*/
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): GlobalSearchHolder {
|
||||
return GlobalSearchHolder(view, adapter as GlobalSearchAdapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind item to view.
|
||||
*/
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: GlobalSearchHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?,
|
||||
) {
|
||||
holder.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to check if two items are equal.
|
||||
*
|
||||
* @return items are equal?
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is GlobalSearchItem) {
|
||||
return source.id == other.source.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return hash code of item.
|
||||
*
|
||||
* @return hashcode
|
||||
*/
|
||||
override fun hashCode(): Int {
|
||||
return source.id.toInt()
|
||||
}
|
||||
}
|
@ -1,265 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.interactor.NetworkToLocalManga
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.toDbManga
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.manga.model.toMangaUpdate
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.toDomainManga
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
|
||||
import eu.kanade.tachiyomi.util.lang.runAsObservable
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subjects.PublishSubject
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import eu.kanade.domain.manga.model.Manga as DomainManga
|
||||
|
||||
open class GlobalSearchPresenter(
|
||||
private val initialQuery: String? = "",
|
||||
private val initialExtensionFilter: String? = null,
|
||||
val sourceManager: SourceManager = Injekt.get(),
|
||||
val preferences: BasePreferences = Injekt.get(),
|
||||
val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
) : BasePresenter<GlobalSearchController>() {
|
||||
|
||||
/**
|
||||
* Enabled sources.
|
||||
*/
|
||||
val sources by lazy { getSourcesToQuery() }
|
||||
|
||||
/**
|
||||
* Fetches the different sources by user settings.
|
||||
*/
|
||||
private var fetchSourcesSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subject which fetches image of given manga.
|
||||
*/
|
||||
private val fetchImageSubject = PublishSubject.create<Pair<List<DomainManga>, Source>>()
|
||||
|
||||
/**
|
||||
* Subscription for fetching images of manga.
|
||||
*/
|
||||
private var fetchImageSubscription: Subscription? = null
|
||||
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
|
||||
private var extensionFilter: String? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
extensionFilter = savedState?.getString(GlobalSearchPresenter::extensionFilter.name)
|
||||
?: initialExtensionFilter
|
||||
|
||||
// Perform a search with previous or initial state
|
||||
search(
|
||||
savedState?.getString(BrowseSourcePresenter::query.name)
|
||||
?: initialQuery.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
fetchSourcesSubscription?.unsubscribe()
|
||||
fetchImageSubscription?.unsubscribe()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSave(state: Bundle) {
|
||||
state.putString(BrowseSourcePresenter::query.name, query)
|
||||
state.putString(GlobalSearchPresenter::extensionFilter.name, extensionFilter)
|
||||
super.onSave(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of enabled sources ordered by language and name, with pinned sources
|
||||
* prioritized.
|
||||
*
|
||||
* @return list containing enabled sources.
|
||||
*/
|
||||
protected open fun getEnabledSources(): List<CatalogueSource> {
|
||||
val languages = sourcePreferences.enabledLanguages().get()
|
||||
val disabledSourceIds = sourcePreferences.disabledSources().get()
|
||||
val pinnedSourceIds = sourcePreferences.pinnedSources().get()
|
||||
|
||||
return sourceManager.getCatalogueSources()
|
||||
.filter { it.lang in languages }
|
||||
.filterNot { it.id.toString() in disabledSourceIds }
|
||||
.sortedWith(compareBy({ it.id.toString() !in pinnedSourceIds }, { "${it.name.lowercase()} (${it.lang})" }))
|
||||
}
|
||||
|
||||
private fun getSourcesToQuery(): List<CatalogueSource> {
|
||||
val filter = extensionFilter
|
||||
val enabledSources = getEnabledSources()
|
||||
var filteredSources: List<CatalogueSource>? = null
|
||||
|
||||
if (!filter.isNullOrEmpty()) {
|
||||
filteredSources = extensionManager.installedExtensionsFlow.value
|
||||
.filter { it.pkgName == filter }
|
||||
.flatMap { it.sources }
|
||||
.filter { it in enabledSources }
|
||||
.filterIsInstance<CatalogueSource>()
|
||||
}
|
||||
|
||||
if (filteredSources != null && filteredSources.isNotEmpty()) {
|
||||
return filteredSources
|
||||
}
|
||||
|
||||
val onlyPinnedSources = sourcePreferences.searchPinnedSourcesOnly().get()
|
||||
val pinnedSourceIds = sourcePreferences.pinnedSources().get()
|
||||
|
||||
return enabledSources
|
||||
.filter { if (onlyPinnedSources) it.id.toString() in pinnedSourceIds else true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a catalogue search item
|
||||
*/
|
||||
protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List<GlobalSearchCardItem>?): GlobalSearchItem {
|
||||
return GlobalSearchItem(source, results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a search for manga per catalogue.
|
||||
*
|
||||
* @param query query on which to search.
|
||||
*/
|
||||
fun search(query: String) {
|
||||
// Return if there's nothing to do
|
||||
if (this.query == query) return
|
||||
|
||||
// Update query
|
||||
this.query = query
|
||||
|
||||
// Create image fetch subscription
|
||||
initializeFetchImageSubscription()
|
||||
|
||||
// Create items with the initial state
|
||||
val initialItems = sources.map { createCatalogueSearchItem(it, null) }
|
||||
var items = initialItems
|
||||
|
||||
val pinnedSourceIds = sourcePreferences.pinnedSources().get()
|
||||
|
||||
fetchSourcesSubscription?.unsubscribe()
|
||||
fetchSourcesSubscription = Observable.from(sources)
|
||||
.flatMap(
|
||||
{ source ->
|
||||
Observable.defer { source.fetchSearchManga(1, query, source.getFilterList()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
|
||||
.map { it.mangas }
|
||||
.map { list -> list.map { runBlocking { networkToLocalManga(it, source.id) } } } // Convert to local manga
|
||||
.doOnNext { fetchImage(it, source) } // Load manga covers
|
||||
.map { list -> createCatalogueSearchItem(source, list.map { GlobalSearchCardItem(it) }) }
|
||||
},
|
||||
5,
|
||||
)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Update matching source with the obtained results
|
||||
.map { result ->
|
||||
items
|
||||
.map { item -> if (item.source == result.source) result else item }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
// Bubble up sources that actually have results
|
||||
{ it.results.isNullOrEmpty() },
|
||||
// Same as initial sort, i.e. pinned first then alphabetically
|
||||
{ it.source.id.toString() !in pinnedSourceIds },
|
||||
{ "${it.source.name.lowercase()} (${it.source.lang})" },
|
||||
),
|
||||
)
|
||||
}
|
||||
// Update current state
|
||||
.doOnNext { items = it }
|
||||
// Deliver initial state
|
||||
.startWith(initialItems)
|
||||
.subscribeLatestCache(
|
||||
{ view, manga ->
|
||||
view.setItems(manga)
|
||||
},
|
||||
{ _, error ->
|
||||
logcat(LogPriority.ERROR, error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a list of manga.
|
||||
*
|
||||
* @param manga the list of manga to initialize.
|
||||
*/
|
||||
private fun fetchImage(manga: List<DomainManga>, source: Source) {
|
||||
fetchImageSubject.onNext(Pair(manga, source))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the initializer of manga details and updates the view if needed.
|
||||
*/
|
||||
private fun initializeFetchImageSubscription() {
|
||||
fetchImageSubscription?.unsubscribe()
|
||||
fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io())
|
||||
.flatMap { (first, source) ->
|
||||
Observable.from(first)
|
||||
.filter { it.thumbnailUrl == null && !it.initialized }
|
||||
.map { Pair(it, source) }
|
||||
.concatMap { runAsObservable { getMangaDetails(it.first.toDbManga(), it.second) } }
|
||||
.map { Pair(source as CatalogueSource, it) }
|
||||
}
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ (source, manga) ->
|
||||
@Suppress("DEPRECATION")
|
||||
view?.onMangaInitialized(source, manga.toDomainManga()!!)
|
||||
},
|
||||
{ error ->
|
||||
logcat(LogPriority.ERROR, error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the given manga.
|
||||
*
|
||||
* @param manga the manga to initialize.
|
||||
* @return The initialized manga.
|
||||
*/
|
||||
private suspend fun getMangaDetails(manga: Manga, source: Source): Manga {
|
||||
val networkManga = source.getMangaDetails(manga.copy())
|
||||
manga.copyFrom(networkManga)
|
||||
manga.initialized = true
|
||||
updateManga.await(manga.toDomainManga()!!.toMangaUpdate())
|
||||
return manga
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga from the database for the given manga from network. It creates a new entry
|
||||
* if the manga is not yet in the database.
|
||||
*
|
||||
* @param sManga the manga from the source.
|
||||
* @return a manga from the database.
|
||||
*/
|
||||
protected open suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): DomainManga {
|
||||
return networkToLocalManga.await(sManga.toDomainManga(sourceId))
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.browse.GlobalSearchScreen
|
||||
import eu.kanade.presentation.util.LocalRouter
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
|
||||
class GlobalSearchScreen(
|
||||
val searchQuery: String = "",
|
||||
val extensionFilter: String = "",
|
||||
) : Screen {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val router = LocalRouter.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel {
|
||||
GlobalSearchScreenModel(
|
||||
initialQuery = searchQuery,
|
||||
initialExtensionFilter = extensionFilter,
|
||||
)
|
||||
}
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
GlobalSearchScreen(
|
||||
state = state,
|
||||
navigateUp = router::popCurrentController,
|
||||
onChangeSearchQuery = screenModel::updateSearchQuery,
|
||||
onSearch = screenModel::search,
|
||||
getManga = { source, manga ->
|
||||
screenModel.getManga(
|
||||
source = source,
|
||||
initialManga = manga,
|
||||
)
|
||||
},
|
||||
onClickSource = {
|
||||
if (!screenModel.incognitoMode.get()) {
|
||||
screenModel.lastUsedSourceId.set(it.id)
|
||||
}
|
||||
router.pushController(BrowseSourceController(it, state.searchQuery))
|
||||
},
|
||||
onClickItem = { router.pushController(MangaController(it.id, true)) },
|
||||
onLongClickItem = { router.pushController(MangaController(it.id, true)) },
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import kotlinx.coroutines.flow.update
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class GlobalSearchScreenModel(
|
||||
initialQuery: String = "",
|
||||
initialExtensionFilter: String = "",
|
||||
preferences: BasePreferences = Injekt.get(),
|
||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
) : SearchScreenModel<GlobalSearchState>(GlobalSearchState(searchQuery = initialQuery)) {
|
||||
|
||||
val incognitoMode = preferences.incognitoMode()
|
||||
val lastUsedSourceId = sourcePreferences.lastUsedSource()
|
||||
|
||||
init {
|
||||
extensionFilter = initialExtensionFilter
|
||||
if (initialQuery.isNotBlank() || initialExtensionFilter.isNotBlank()) {
|
||||
search(initialQuery)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getEnabledSources(): List<CatalogueSource> {
|
||||
val enabledLanguages = sourcePreferences.enabledLanguages().get()
|
||||
val disabledSources = sourcePreferences.disabledSources().get()
|
||||
val pinnedSources = sourcePreferences.pinnedSources().get()
|
||||
|
||||
return sourceManager.getCatalogueSources()
|
||||
.filter { it.lang in enabledLanguages }
|
||||
.filterNot { "${it.id}" in disabledSources }
|
||||
.sortedWith(compareBy({ "${it.id}" !in pinnedSources }, { "${it.name.lowercase()} (${it.lang})" }))
|
||||
}
|
||||
|
||||
override fun updateSearchQuery(query: String?) {
|
||||
mutableState.update {
|
||||
it.copy(searchQuery = query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateItems(items: Map<CatalogueSource, GlobalSearchItemResult>) {
|
||||
mutableState.update {
|
||||
it.copy(items = items)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItems(): Map<CatalogueSource, GlobalSearchItemResult> {
|
||||
return mutableState.value.items
|
||||
}
|
||||
}
|
||||
|
||||
sealed class GlobalSearchItemResult {
|
||||
object Loading : GlobalSearchItemResult()
|
||||
|
||||
data class Error(
|
||||
val throwable: Throwable,
|
||||
) : GlobalSearchItemResult()
|
||||
|
||||
data class Success(
|
||||
val result: List<Manga>,
|
||||
) : GlobalSearchItemResult() {
|
||||
val isEmpty: Boolean
|
||||
get() = result.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class GlobalSearchState(
|
||||
val searchQuery: String? = null,
|
||||
val items: Map<CatalogueSource, GlobalSearchItemResult> = emptyMap(),
|
||||
) {
|
||||
|
||||
val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading }
|
||||
|
||||
val total: Int = items.size
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.coroutineScope
|
||||
import eu.kanade.domain.manga.interactor.GetManga
|
||||
import eu.kanade.domain.manga.interactor.NetworkToLocalManga
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.Manga
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.manga.model.toMangaUpdate
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.util.lang.awaitSingle
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
abstract class SearchScreenModel<T>(
|
||||
initialState: T,
|
||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
) : StateScreenModel<T>(initialState) {
|
||||
|
||||
private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher()
|
||||
|
||||
protected var query: String? = null
|
||||
protected lateinit var extensionFilter: String
|
||||
|
||||
private val sources by lazy { getSelectedSources() }
|
||||
|
||||
@Composable
|
||||
fun getManga(source: CatalogueSource, initialManga: Manga): State<Manga> {
|
||||
return produceState(initialValue = initialManga) {
|
||||
getManga.subscribe(initialManga.url, initialManga.source)
|
||||
.collectLatest { manga ->
|
||||
if (manga == null) return@collectLatest
|
||||
withIOContext {
|
||||
initializeManga(source, manga)
|
||||
}
|
||||
value = manga
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a manga.
|
||||
*
|
||||
* @param source to interact with
|
||||
* @param manga to initialize.
|
||||
*/
|
||||
private suspend fun initializeManga(source: CatalogueSource, manga: Manga) {
|
||||
if (manga.thumbnailUrl != null || manga.initialized) return
|
||||
withNonCancellableContext {
|
||||
try {
|
||||
val networkManga = source.getMangaDetails(manga.toSManga())
|
||||
val updatedManga = manga.copyFrom(networkManga)
|
||||
.copy(initialized = true)
|
||||
|
||||
updateManga.await(updatedManga.toMangaUpdate())
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getEnabledSources(): List<CatalogueSource>
|
||||
|
||||
fun getSelectedSources(): List<CatalogueSource> {
|
||||
val filter = extensionFilter
|
||||
|
||||
val enabledSources = getEnabledSources()
|
||||
|
||||
if (filter.isEmpty()) {
|
||||
val shouldSearchPinnedOnly = sourcePreferences.searchPinnedSourcesOnly().get()
|
||||
val pinnedSources = sourcePreferences.pinnedSources().get()
|
||||
|
||||
return enabledSources.filter {
|
||||
if (shouldSearchPinnedOnly) {
|
||||
"${it.id}" in pinnedSources
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extensionManager.installedExtensionsFlow.value
|
||||
.filter { it.pkgName == filter }
|
||||
.flatMap { it.sources }
|
||||
.filter { it in enabledSources }
|
||||
.filterIsInstance<CatalogueSource>()
|
||||
}
|
||||
|
||||
abstract fun updateSearchQuery(query: String?)
|
||||
|
||||
abstract fun updateItems(items: Map<CatalogueSource, GlobalSearchItemResult>)
|
||||
|
||||
abstract fun getItems(): Map<CatalogueSource, GlobalSearchItemResult>
|
||||
|
||||
fun getAndUpdateItems(function: (Map<CatalogueSource, GlobalSearchItemResult>) -> Map<CatalogueSource, GlobalSearchItemResult>) {
|
||||
updateItems(function(getItems()))
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
if (this.query == query) return
|
||||
|
||||
this.query = query
|
||||
|
||||
val initialItems = getSelectedSources().associateWith { GlobalSearchItemResult.Loading }
|
||||
updateItems(initialItems)
|
||||
|
||||
val pinnedSources = sourcePreferences.pinnedSources().get()
|
||||
|
||||
val comparator = { mutableMap: MutableMap<CatalogueSource, GlobalSearchItemResult> ->
|
||||
compareBy<CatalogueSource>(
|
||||
{ mutableMap[it] is GlobalSearchItemResult.Success },
|
||||
{ "${it.id}" in pinnedSources },
|
||||
{ "${it.name.lowercase()} (${it.lang})" },
|
||||
)
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
sources.forEach { source ->
|
||||
val page = try {
|
||||
withContext(coroutineDispatcher) {
|
||||
source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
getAndUpdateItems { items ->
|
||||
val mutableMap = items.toMutableMap()
|
||||
mutableMap[source] = GlobalSearchItemResult.Error(throwable = e)
|
||||
mutableMap.toSortedMap(comparator(mutableMap))
|
||||
mutableMap.toMap()
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val titles = page.mangas.map {
|
||||
withIOContext {
|
||||
networkToLocalManga.await(it.toDomainManga(source.id))
|
||||
}
|
||||
}
|
||||
|
||||
getAndUpdateItems { items ->
|
||||
val mutableMap = items.toMutableMap()
|
||||
mutableMap[source] = GlobalSearchItemResult.Success(titles)
|
||||
mutableMap.toSortedMap(comparator(mutableMap))
|
||||
mutableMap.toMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -178,7 +178,7 @@ object LibraryScreen : Screen {
|
||||
},
|
||||
onRefresh = onClickRefresh,
|
||||
onGlobalSearchClicked = {
|
||||
router.pushController(GlobalSearchController(screenModel.state.value.searchQuery))
|
||||
router.pushController(GlobalSearchController(screenModel.state.value.searchQuery ?: ""))
|
||||
},
|
||||
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
|
||||
getDisplayModeForPage = { state.categories[it].display },
|
||||
|
@ -461,7 +461,7 @@ class MainActivity : BaseActivity() {
|
||||
if (router.backstackSize > 1) {
|
||||
router.popToRoot()
|
||||
}
|
||||
router.pushController(GlobalSearchController(query, filter))
|
||||
router.pushController(GlobalSearchController(query, filter ?: ""))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
|
@ -50,7 +50,7 @@ import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.isLocalOrStub
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
@ -113,7 +113,7 @@ class MangaScreen(
|
||||
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
|
||||
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
|
||||
onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite },
|
||||
onMigrateClicked = { migrateManga(router, screenModel.manga!!) }.takeIf { successState.manga.favorite },
|
||||
onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite },
|
||||
onMultiBookmarkClicked = screenModel::bookmarkChapters,
|
||||
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
|
||||
onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
|
||||
@ -321,14 +321,6 @@ class MangaScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates source migration for the specific manga.
|
||||
*/
|
||||
private fun migrateManga(router: Router, manga: Manga) {
|
||||
val controller = SearchController(manga)
|
||||
router.pushController(controller)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy Manga URL to Clipboard
|
||||
*/
|
||||
|
@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout 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">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
tools:listitem="@layout/global_search_controller_card" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="2dp"
|
||||
android:max="100"
|
||||
android:visibility="gone"
|
||||
tools:progress="50"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0.75"
|
||||
android:background="?attr/colorSurface" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<eu.kanade.tachiyomi.widget.EmptyView
|
||||
android:id="@+id/empty_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
@ -1,86 +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"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/title_wrapper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintBottom_toTopOf="@+id/subtitle"
|
||||
app:layout_constraintEnd_toStartOf="@+id/title_more_icon"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/title_more_icon"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title"
|
||||
tools:text="English"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/title_more_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/all"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_arrow_forward_24dp"
|
||||
app:tint="?android:attr/textColorPrimary" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/no_results_found"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:text="@string/no_results_found"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.Tachiyomi.CircularProgressIndicator.Small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/global_search_controller_card_item" />
|
||||
|
||||
</LinearLayout>
|
@ -1,84 +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="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="4dp"
|
||||
android:background="@drawable/library_item_selector"
|
||||
android:padding="4dp">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/card"
|
||||
android:layout_width="112dp"
|
||||
android:layout_height="144dp"
|
||||
android:background="@drawable/rounded_rectangle"
|
||||
app:layout_constraintDimensionRatio="h,5:7"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.Tachiyomi.CircularProgressIndicator.Small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:scaleType="centerCrop"
|
||||
tools:ignore="ContentDescription"
|
||||
tools:src="@mipmap/ic_launcher" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/badges"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="@drawable/rounded_rectangle">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/favorite_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSecondary"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="3dp"
|
||||
android:paddingTop="1dp"
|
||||
android:paddingEnd="3dp"
|
||||
android:paddingBottom="1dp"
|
||||
android:fontFamily="sans-serif-condensed"
|
||||
android:text="@string/in_library"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSecondary"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:padding="4dp"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="@+id/card"
|
||||
app:layout_constraintStart_toStartOf="@+id/card"
|
||||
app:layout_constraintTop_toBottomOf="@+id/card"
|
||||
tools:text="Sample name" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
@ -1,12 +0,0 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_search"
|
||||
android:icon="@drawable/ic_search_24dp"
|
||||
android:title="@string/action_search"
|
||||
app:actionViewClass="eu.kanade.tachiyomi.widget.TachiyomiSearchView"
|
||||
app:iconTint="?attr/colorOnSurface"
|
||||
app:showAsAction="collapseActionView|ifRoom" />
|
||||
|
||||
</menu>
|
Loading…
Reference in New Issue
Block a user