mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 07:11:49 +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 url: String?,
|
||||||
val lastModified: Long,
|
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.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import eu.kanade.domain.manga.model.Manga
|
import eu.kanade.domain.manga.model.Manga
|
||||||
import eu.kanade.domain.manga.model.MangaCover
|
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.CommonMangaItemDefaults
|
||||||
import eu.kanade.presentation.components.MangaComfortableGridItem
|
import eu.kanade.presentation.components.MangaComfortableGridItem
|
||||||
import eu.kanade.presentation.util.plus
|
import eu.kanade.presentation.util.plus
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseSourceComfortableGrid(
|
fun BrowseSourceComfortableGrid(
|
||||||
@ -76,9 +74,7 @@ fun BrowseSourceComfortableGridItem(
|
|||||||
),
|
),
|
||||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||||
coverBadgeStart = {
|
coverBadgeStart = {
|
||||||
if (manga.favorite) {
|
InLibraryBadge(enabled = manga.favorite)
|
||||||
Badge(text = stringResource(R.string.in_library))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import eu.kanade.domain.manga.model.Manga
|
import eu.kanade.domain.manga.model.Manga
|
||||||
import eu.kanade.domain.manga.model.MangaCover
|
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.CommonMangaItemDefaults
|
||||||
import eu.kanade.presentation.components.MangaCompactGridItem
|
import eu.kanade.presentation.components.MangaCompactGridItem
|
||||||
import eu.kanade.presentation.util.plus
|
import eu.kanade.presentation.util.plus
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseSourceCompactGrid(
|
fun BrowseSourceCompactGrid(
|
||||||
@ -76,9 +74,7 @@ private fun BrowseSourceCompactGridItem(
|
|||||||
),
|
),
|
||||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||||
coverBadgeStart = {
|
coverBadgeStart = {
|
||||||
if (manga.favorite) {
|
InLibraryBadge(enabled = manga.favorite)
|
||||||
Badge(text = stringResource(R.string.in_library))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
@ -4,19 +4,17 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.items
|
import androidx.paging.compose.items
|
||||||
import eu.kanade.domain.manga.model.Manga
|
import eu.kanade.domain.manga.model.Manga
|
||||||
import eu.kanade.domain.manga.model.MangaCover
|
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.CommonMangaItemDefaults
|
||||||
import eu.kanade.presentation.components.LazyColumn
|
import eu.kanade.presentation.components.LazyColumn
|
||||||
import eu.kanade.presentation.components.MangaListItem
|
import eu.kanade.presentation.components.MangaListItem
|
||||||
import eu.kanade.presentation.util.plus
|
import eu.kanade.presentation.util.plus
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseSourceList(
|
fun BrowseSourceList(
|
||||||
@ -70,9 +68,7 @@ fun BrowseSourceListItem(
|
|||||||
),
|
),
|
||||||
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
|
||||||
badge = {
|
badge = {
|
||||||
if (manga.favorite) {
|
InLibraryBadge(enabled = manga.favorite)
|
||||||
Badge(text = stringResource(R.string.in_library))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onClick = onClick,
|
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 medium = 16.dp
|
||||||
|
|
||||||
val small = 8.dp
|
val small = 8.dp
|
||||||
|
|
||||||
|
val tiny = 4.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
val MaterialTheme.padding: Padding
|
val MaterialTheme.padding: Padding
|
||||||
|
@ -13,8 +13,7 @@ import eu.kanade.presentation.browse.MigrateMangaScreen
|
|||||||
import eu.kanade.presentation.components.LoadingScreen
|
import eu.kanade.presentation.components.LoadingScreen
|
||||||
import eu.kanade.presentation.util.LocalRouter
|
import eu.kanade.presentation.util.LocalRouter
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||||
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
|
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
@ -41,12 +40,8 @@ data class MigrationMangaScreen(
|
|||||||
navigateUp = navigator::pop,
|
navigateUp = navigator::pop,
|
||||||
title = state.source!!.name,
|
title = state.source!!.name,
|
||||||
state = state,
|
state = state,
|
||||||
onClickItem = {
|
onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
|
||||||
router.pushController(SearchController(it.id))
|
onClickCover = { navigator.push(MangaScreen(it.id)) },
|
||||||
},
|
|
||||||
onClickCover = {
|
|
||||||
navigator.push(MangaScreen(it.id))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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 android.os.Bundle
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import eu.kanade.domain.manga.model.Manga
|
import eu.kanade.domain.manga.model.Manga
|
||||||
import eu.kanade.presentation.browse.SourceSearchScreen
|
import eu.kanade.presentation.browse.SourceSearchScreen
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
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.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.ui.webview.WebViewActivity
|
||||||
import eu.kanade.tachiyomi.util.system.getSerializableCompat
|
import eu.kanade.tachiyomi.util.system.getSerializableCompat
|
||||||
|
|
||||||
@ -25,7 +32,6 @@ class SourceSearchController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
|
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
|
||||||
private var newManga: Manga? = null
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun ComposeContent() {
|
override fun ComposeContent() {
|
||||||
@ -34,11 +40,7 @@ class SourceSearchController(
|
|||||||
navigateUp = { router.popCurrentController() },
|
navigateUp = { router.popCurrentController() },
|
||||||
onFabClick = { filterSheet?.show() },
|
onFabClick = { filterSheet?.show() },
|
||||||
onMangaClick = {
|
onMangaClick = {
|
||||||
newManga = it
|
presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(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)
|
|
||||||
},
|
},
|
||||||
onWebViewClick = f@{
|
onWebViewClick = f@{
|
||||||
val source = presenter.source as? HttpSource ?: return@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) {
|
LaunchedEffect(presenter.filters) {
|
||||||
initFilterSheet()
|
initFilterSheet()
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,8 @@ open class BrowseSourceController(bundle: Bundle) :
|
|||||||
|
|
||||||
val onDismissRequest = { presenter.dialog = null }
|
val onDismissRequest = { presenter.dialog = null }
|
||||||
when (val dialog = presenter.dialog) {
|
when (val dialog = presenter.dialog) {
|
||||||
|
null -> {}
|
||||||
|
is Dialog.Migrate -> {}
|
||||||
is Dialog.AddDuplicateManga -> {
|
is Dialog.AddDuplicateManga -> {
|
||||||
DuplicateMangaDialog(
|
DuplicateMangaDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
@ -111,7 +113,6 @@ open class BrowseSourceController(bundle: Bundle) :
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
null -> {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = ::navigateUp)
|
BackHandler(onBack = ::navigateUp)
|
||||||
|
@ -334,6 +334,7 @@ open class BrowseSourcePresenter(
|
|||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val initialSelection: List<CheckboxState.State<Category>>,
|
val initialSelection: List<CheckboxState.State<Category>>,
|
||||||
) : Dialog()
|
) : 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
|
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
|
||||||
|
|
||||||
import android.os.Bundle
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.LayoutInflater
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import android.view.Menu
|
import cafe.adriel.voyager.navigator.Navigator
|
||||||
import android.view.MenuInflater
|
import eu.kanade.presentation.util.LocalRouter
|
||||||
import android.view.MenuItem
|
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
class GlobalSearchController(
|
||||||
* This controller shows and manages the different search result in global search.
|
val searchQuery: String = "",
|
||||||
* This controller should only handle UI actions, IO actions should be done by [GlobalSearchPresenter]
|
val extensionFilter: String = "",
|
||||||
* [GlobalSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search
|
) : BasicFullComposeController() {
|
||||||
*/
|
|
||||||
open class GlobalSearchController(
|
|
||||||
protected val initialQuery: String? = null,
|
|
||||||
private val extensionFilter: String? = null,
|
|
||||||
) : SearchableNucleusController<GlobalSearchControllerBinding, GlobalSearchPresenter>(),
|
|
||||||
GlobalSearchCardAdapter.OnMangaClickListener,
|
|
||||||
GlobalSearchAdapter.OnTitleClickListener {
|
|
||||||
|
|
||||||
private val preferences: BasePreferences by injectLazy()
|
@Composable
|
||||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
override fun ComposeContent() {
|
||||||
|
CompositionLocalProvider(LocalRouter provides router) {
|
||||||
/**
|
Navigator(
|
||||||
* Adapter containing search results grouped by lang.
|
screen = GlobalSearchScreen(
|
||||||
*/
|
searchQuery = searchQuery,
|
||||||
protected var adapter: GlobalSearchAdapter? = null
|
extensionFilter = extensionFilter,
|
||||||
|
),
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
onRefresh = onClickRefresh,
|
||||||
onGlobalSearchClicked = {
|
onGlobalSearchClicked = {
|
||||||
router.pushController(GlobalSearchController(screenModel.state.value.searchQuery))
|
router.pushController(GlobalSearchController(screenModel.state.value.searchQuery ?: ""))
|
||||||
},
|
},
|
||||||
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
|
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
|
||||||
getDisplayModeForPage = { state.categories[it].display },
|
getDisplayModeForPage = { state.categories[it].display },
|
||||||
|
@ -461,7 +461,7 @@ class MainActivity : BaseActivity() {
|
|||||||
if (router.backstackSize > 1) {
|
if (router.backstackSize > 1) {
|
||||||
router.popToRoot()
|
router.popToRoot()
|
||||||
}
|
}
|
||||||
router.pushController(GlobalSearchController(query, filter))
|
router.pushController(GlobalSearchController(query, filter ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
@ -50,7 +50,7 @@ import eu.kanade.tachiyomi.source.Source
|
|||||||
import eu.kanade.tachiyomi.source.isLocalOrStub
|
import eu.kanade.tachiyomi.source.isLocalOrStub
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
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.browse.BrowseSourceController
|
||||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||||
@ -113,7 +113,7 @@ class MangaScreen(
|
|||||||
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
|
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
|
||||||
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
|
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
|
||||||
onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite },
|
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,
|
onMultiBookmarkClicked = screenModel::bookmarkChapters,
|
||||||
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
|
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
|
||||||
onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
|
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
|
* 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