Use Compose on BrowseSourceScreens (#7901)

This commit is contained in:
Andreas 2022-08-31 20:41:35 +02:00 committed by GitHub
parent bb54a81ef0
commit d4b764fa31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1760 additions and 2024 deletions

View File

@ -27,6 +27,10 @@ class MangaRepositoryImpl(
return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) } return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
} }
override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> {
return handler.subscribeToOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
}
override suspend fun getFavorites(): List<Manga> { override suspend fun getFavorites(): List<Manga> {
return handler.awaitList { mangasQueries.getFavorites(mangaMapper) } return handler.awaitList { mangasQueries.getFavorites(mangaMapper) }
} }

View File

@ -26,4 +26,8 @@ class GetManga(
suspend fun await(url: String, sourceId: Long): Manga? { suspend fun await(url: String, sourceId: Long): Manga? {
return mangaRepository.getMangaByUrlAndSourceId(url, sourceId) return mangaRepository.getMangaByUrlAndSourceId(url, sourceId)
} }
fun subscribe(url: String, sourceId: Long): Flow<Manga?> {
return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId)
}
} }

View File

@ -13,6 +13,8 @@ interface MangaRepository {
suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga? suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga?
fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?>
suspend fun getFavorites(): List<Manga> suspend fun getFavorites(): List<Manga>
suspend fun getLibraryManga(): List<LibraryManga> suspend fun getLibraryManga(): List<LibraryManga>

View File

@ -0,0 +1,67 @@
package eu.kanade.presentation.browse
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.components.BrowseLatestToolbar
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@Composable
fun BrowseLatestScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
val onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
Scaffold(
topBar = {
BrowseLatestToolbar(
navigateUp = navigateUp,
source = presenter.source!!,
displayMode = presenter.displayMode,
onDisplayModeChange = { presenter.displayMode = it },
onHelpClick = onHelpClick,
onWebViewClick = onWebViewClick,
)
},
) { paddingValues ->
BrowseSourceContent(
source = presenter.source,
mangaList = presenter.getMangaList().collectAsLazyPagingItems(),
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = remember { SnackbarHostState() },
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}

View File

@ -0,0 +1,210 @@
package eu.kanade.presentation.browse
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
import eu.kanade.presentation.browse.components.BrowseSourceList
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.widget.EmptyView
@Composable
fun BrowseSourceScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
onFabClick: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
val onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
Scaffold(
topBar = {
BrowseSourceToolbar(
state = presenter,
source = presenter.source!!,
displayMode = presenter.displayMode,
onDisplayModeChange = onDisplayModeChange,
navigateUp = navigateUp,
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
onSearch = { presenter.search() },
)
},
floatingActionButton = {
if (presenter.filters.isNotEmpty()) {
ExtendedFloatingActionButton(
modifier = Modifier.navigationBarsPadding(),
text = { Text(text = stringResource(id = R.string.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = onFabClick,
)
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { paddingValues ->
BrowseSourceContent(
source = presenter.source,
mangaList = mangaList,
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}
@Composable
fun BrowseSourceContent(
source: CatalogueSource?,
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
displayMode: LibraryDisplayMode,
snackbarHostState: SnackbarHostState,
contentPadding: PaddingValues,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
onLocalSourceHelpClick: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val context = LocalContext.current
val errorState = mangaList.loadState.refresh.takeIf { it is LoadState.Error }
?: mangaList.loadState.append.takeIf { it is LoadState.Error }
val getErrorMessage: (LoadState.Error) -> String = { state ->
when {
state.error is NoResultsException -> context.getString(R.string.no_results_found)
state.error.message == null -> ""
state.error.message!!.startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}"
else -> state.error.message!!
}
}
LaunchedEffect(errorState) {
if (mangaList.itemCount > 0 && errorState != null && errorState is LoadState.Error) {
val result = snackbarHostState.showSnackbar(
message = getErrorMessage(errorState),
actionLabel = context.getString(R.string.action_webview_refresh),
duration = SnackbarDuration.Indefinite,
)
when (result) {
SnackbarResult.Dismissed -> snackbarHostState.currentSnackbarData?.dismiss()
SnackbarResult.ActionPerformed -> mangaList.refresh()
}
}
}
if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
EmptyScreen(
message = getErrorMessage(errorState),
actions = if (source is LocalSource) {
listOf(
EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { onLocalSourceHelpClick() },
)
} else {
listOf(
EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp) { mangaList.refresh() },
EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { onWebViewClick() },
EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { onHelpClick() },
)
},
)
return
}
when (displayMode) {
LibraryDisplayMode.ComfortableGrid -> {
BrowseSourceComfortableGrid(
mangaList = mangaList,
getMangaState = getMangaState,
columns = columns,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
LibraryDisplayMode.List -> {
BrowseSourceList(
mangaList = mangaList,
getMangaState = getMangaState,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
else -> {
BrowseSourceCompactGrid(
mangaList = mangaList,
getMangaState = getMangaState,
columns = columns,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}
}

View File

@ -0,0 +1,37 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
@Stable
interface BrowseSourceState {
val source: CatalogueSource?
var searchQuery: String?
val currentQuery: String
val filters: FilterList
val filterItems: List<IFlexible<*>>
val appliedFilters: FilterList
var dialog: BrowseSourcePresenter.Dialog?
}
fun BrowseSourceState(initialQuery: String?): BrowseSourceState {
return BrowseSourceStateImpl(initialQuery)
}
class BrowseSourceStateImpl(initialQuery: String?) : BrowseSourceState {
override var source: CatalogueSource? by mutableStateOf(null)
override var searchQuery: String? by mutableStateOf(initialQuery)
override var currentQuery: String by mutableStateOf(initialQuery ?: "")
override var filters: FilterList by mutableStateOf(FilterList())
override val filterItems: List<IFlexible<*>> by derivedStateOf { filters.toItems() }
override var appliedFilters by mutableStateOf(FilterList())
override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null)
}

View File

@ -0,0 +1,22 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Composable
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
@Composable
fun SourceSearchScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onFabClick: () -> Unit,
onClickManga: (Manga) -> Unit,
) {
BrowseSourceScreen(
presenter = presenter,
navigateUp = navigateUp,
onDisplayModeChange = { presenter.displayMode = (it) },
onFabClick = onFabClick,
onMangaClick = onClickManga,
onMangaLongClick = onClickManga,
)
}

View File

@ -0,0 +1,105 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.ViewModule
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
@Composable
fun BrowseLatestToolbar(
navigateUp: () -> Unit,
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
onHelpClick: () -> Unit,
onWebViewClick: () -> Unit,
) {
AppBar(
navigateUp = navigateUp,
title = source.name,
actions = {
var selectingDisplayMode by remember { mutableStateOf(false) }
AppBarActions(
actions = listOf(
AppBar.Action(
title = "display_mode",
icon = Icons.Filled.ViewModule,
onClick = { selectingDisplayMode = true },
),
if (source is LocalSource) {
AppBar.Action(
title = "help",
icon = Icons.Outlined.Help,
onClick = onHelpClick,
)
} else {
AppBar.Action(
title = "webview",
icon = Icons.Outlined.Public,
onClick = onWebViewClick,
)
},
),
)
DropdownMenu(
expanded = selectingDisplayMode,
onDismissRequest = { selectingDisplayMode = false },
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.ComfortableGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.CompactGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_list)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.List) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
}
},
)
}

View File

@ -0,0 +1,106 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaGridComfortableText
import eu.kanade.presentation.library.components.MangaGridCover
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceComfortableGrid(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyVerticalGrid(
columns = columns,
contentPadding = PaddingValues(8.dp) + contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceComfortableGridItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceComfortableGridItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
Column(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
) {
MangaGridCover(
cover = {
MangaCover.Book(
modifier = Modifier
.fillMaxWidth()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = manga.thumbnailUrl,
)
},
badgesStart = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
)
MangaGridComfortableText(
text = manga.title,
)
}
}

View File

@ -0,0 +1,129 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaGridCompactText
import eu.kanade.presentation.library.components.MangaGridCover
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceCompactGrid(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyVerticalGrid(
columns = columns,
contentPadding = PaddingValues(8.dp) + contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceCompactGridItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceCompactGridItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
MangaGridCover(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
cover = {
MangaCover.Book(
modifier = Modifier
.fillMaxHeight()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = eu.kanade.domain.manga.model.MangaCover(
manga.id,
manga.source,
manga.favorite,
manga.thumbnailUrl,
manga.coverLastModified,
),
)
},
badgesStart = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
content = {
Box(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
MangaGridCompactText(manga.title)
},
)
}

View File

@ -0,0 +1,41 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.TextButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun RemoveMangaDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(id = R.string.action_remove))
}
},
title = {
Text(text = stringResource(id = R.string.are_you_sure))
},
text = {
Text(text = stringResource(R.string.remove_manga))
},
)
}

View File

@ -0,0 +1,94 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.items
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaListItem
import eu.kanade.presentation.library.components.MangaListItemContent
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.verticalPadding
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceList(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyColumn(
contentPadding = contentPadding,
) {
item {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList) { initialManga ->
initialManga ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceListItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceListItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
MangaListItem(
coverContent = {
MangaCover.Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = manga.thumbnailUrl,
)
},
onClick = onClick,
onLongClick = onLongClick,
badges = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
content = {
MangaListItemContent(text = manga.title)
},
)
}

View File

@ -0,0 +1,25 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Arrangement
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.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun BrowseSourceLoadingItem() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
)
}
}

View File

@ -0,0 +1,206 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import eu.kanade.presentation.browse.BrowseSourceState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import kotlinx.coroutines.delay
@Composable
fun BrowseSourceToolbar(
state: BrowseSourceState,
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
navigateUp: () -> Unit,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
onSearch: () -> Unit,
) {
if (state.searchQuery == null) {
BrowseSourceRegularToolbar(
source = source,
displayMode = displayMode,
onDisplayModeChange = onDisplayModeChange,
navigateUp = navigateUp,
onSearchClick = { state.searchQuery = "" },
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
)
} else {
BrowseSourceSearchToolbar(
searchQuery = state.searchQuery!!,
onSearchQueryChanged = { state.searchQuery = it },
navigateUp = {
state.searchQuery = null
onSearch()
},
onResetClick = { state.searchQuery = "" },
onSearchClick = onSearch,
)
}
}
@Composable
fun BrowseSourceRegularToolbar(
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
navigateUp: () -> Unit,
onSearchClick: () -> Unit,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
) {
AppBar(
navigateUp = navigateUp,
title = source.name,
actions = {
var selectingDisplayMode by remember { mutableStateOf(false) }
AppBarActions(
actions = listOf(
AppBar.Action(
title = "search",
icon = Icons.Outlined.Search,
onClick = onSearchClick,
),
AppBar.Action(
title = "display_mode",
icon = Icons.Filled.ViewModule,
onClick = { selectingDisplayMode = true },
),
if (source is LocalSource) {
AppBar.Action(
title = "help",
icon = Icons.Outlined.Help,
onClick = onHelpClick,
)
} else {
AppBar.Action(
title = "webview",
icon = Icons.Outlined.Public,
onClick = onWebViewClick,
)
},
),
)
DropdownMenu(
expanded = selectingDisplayMode,
onDismissRequest = { selectingDisplayMode = false },
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.ComfortableGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.CompactGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_list)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.List) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
}
},
)
}
@Composable
fun BrowseSourceSearchToolbar(
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
navigateUp: () -> Unit,
onResetClick: () -> Unit,
onSearchClick: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
AppBar(
navigateUp = navigateUp,
titleContent = {
BasicTextField(
value = searchQuery,
onValueChange = onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
onSearchClick()
},
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
)
},
actions = {
AppBarActions(
actions = listOf(
AppBar.Action(
title = "clear",
icon = Icons.Outlined.Clear,
onClick = onResetClick,
),
),
)
},
)
LaunchedEffect(Unit) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(100)
focusRequester.requestFocus()
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.presentation.manga.components package eu.kanade.presentation.components
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer

View File

@ -78,13 +78,22 @@ fun LibraryComfortableGridItem(
isLocal = item.isLocal, isLocal = item.isLocal,
language = item.sourceLanguage, language = item.sourceLanguage,
) )
Text( MangaGridComfortableText(
modifier = Modifier.padding(4.dp),
text = manga.title, text = manga.title,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
) )
} }
} }
} }
@Composable
fun MangaGridComfortableText(
text: String,
) {
Text(
modifier = Modifier.padding(4.dp),
text = text,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}

View File

@ -3,6 +3,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -97,20 +98,27 @@ fun LibraryCompactGridItem(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) )
Text( MangaGridCompactText(manga.title)
text = manga.title,
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomStart),
color = Color.White,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall.copy(
shadow = Shadow(
color = Color.Black,
blurRadius = 4f,
),
),
)
} }
} }
@Composable
fun BoxScope.MangaGridCompactText(
text: String,
) {
Text(
text = text,
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomStart),
color = Color.White,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall.copy(
shadow = Shadow(
color = Color.Black,
blurRadius = 4f,
),
),
)
}

View File

@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -16,6 +17,41 @@ import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.MangaCover import eu.kanade.presentation.components.MangaCover
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@Composable
fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
badgesStart: (@Composable RowScope.() -> Unit)? = null,
badgesEnd: (@Composable RowScope.() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
content()
if (badgesStart != null) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart),
content = badgesStart,
)
}
if (badgesEnd != null) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
content = badgesEnd,
)
}
}
}
@Composable @Composable
fun LibraryGridCover( fun LibraryGridCover(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -26,54 +62,41 @@ fun LibraryGridCover(
language: String, language: String,
content: @Composable BoxScope.() -> Unit = {}, content: @Composable BoxScope.() -> Unit = {},
) { ) {
Box( MangaGridCover(
modifier = modifier modifier = modifier,
.fillMaxWidth() cover = {
.aspectRatio(MangaCover.Book.ratio), MangaCover.Book(
) { modifier = Modifier.fillMaxWidth(),
MangaCover.Book( data = mangaCover,
modifier = Modifier.fillMaxWidth(), )
data = mangaCover, },
) badgesStart = {
content() if (downloadCount > 0) {
if (downloadCount > 0 || unreadCount > 0) { Badge(
BadgeGroup( text = "$downloadCount",
modifier = Modifier color = MaterialTheme.colorScheme.tertiary,
.padding(4.dp) textColor = MaterialTheme.colorScheme.onTertiary,
.align(Alignment.TopStart), )
) {
if (downloadCount > 0) {
Badge(
text = "$downloadCount",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (unreadCount > 0) {
Badge(text = "$unreadCount")
}
} }
} if (unreadCount > 0) {
if (isLocal || language.isNotEmpty()) { Badge(text = "$unreadCount")
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
} else if (language.isNotEmpty()) {
Badge(
text = language,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
} }
} },
} badgesEnd = {
if (isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
} else if (language.isNotEmpty()) {
Badge(
text = language,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
},
content = content,
)
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -19,6 +20,7 @@ import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.BadgeGroup import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.FastScrollLazyColumn import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.MangaCover.Square
import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.bottomNavPaddingValues import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
@ -74,62 +76,109 @@ fun LibraryListItem(
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
) { ) {
val manga = item.manga val manga = item.manga
MangaListItem(
modifier = Modifier.selectedBackground(isSelected),
title = manga.title,
cover = MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
onClick = { onClick(manga) },
onLongClick = { onLongClick(manga) },
) {
if (item.downloadCount > 0) {
Badge(
text = "${item.downloadCount}",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.unreadCount > 0) {
Badge(text = "${item.unreadCount}")
}
if (item.isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) {
Badge(
text = item.sourceLanguage,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
}
@Composable
fun MangaListItem(
modifier: Modifier = Modifier,
title: String,
cover: MangaCover,
onClick: () -> Unit,
onLongClick: () -> Unit = onClick,
badges: @Composable RowScope.() -> Unit,
) {
MangaListItem(
modifier = modifier,
coverContent = {
Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight(),
data = cover,
)
},
badges = badges,
onClick = onClick,
onLongClick = onLongClick,
content = {
MangaListItemContent(title)
},
)
}
@Composable
fun MangaListItem(
modifier: Modifier = Modifier,
coverContent: @Composable RowScope.() -> Unit,
badges: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
Row( Row(
modifier = Modifier modifier = modifier
.selectedBackground(isSelected)
.height(56.dp) .height(56.dp)
.combinedClickable( .combinedClickable(
onClick = { onClick(manga) }, onClick = onClick,
onLongClick = { onLongClick(manga) }, onLongClick = onLongClick,
) )
.padding(horizontal = horizontalPadding), .padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
eu.kanade.presentation.components.MangaCover.Square( coverContent()
modifier = Modifier content()
.padding(vertical = verticalPadding) BadgeGroup(content = badges)
.fillMaxHeight(),
data = MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
)
Text(
text = manga.title,
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
maxLines = 2,
style = MaterialTheme.typography.bodyMedium,
)
BadgeGroup {
if (item.downloadCount > 0) {
Badge(
text = "${item.downloadCount}",
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.unreadCount > 0) {
Badge(text = "${item.unreadCount}")
}
if (item.isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) {
Badge(
text = item.sourceLanguage,
color = MaterialTheme.colorScheme.tertiary,
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
} }
} }
@Composable
fun RowScope.MangaListItemContent(
text: String,
) {
Text(
text = text,
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
maxLines = 2,
style = MaterialTheme.typography.bodyMedium,
)
}

View File

@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import eu.kanade.domain.manga.model.Manga as DomainManga
/** /**
* Class used to create cover cache. * Class used to create cover cache.
@ -87,6 +88,20 @@ class CoverCache(private val context: Context) {
return deleted return deleted
} }
fun deleteFromCache(manga: DomainManga, deleteCustomCover: Boolean = false): Int {
var amountDeleted = 0
getCoverFile(manga.thumbnailUrl)?.let {
if (it.exists() && it.delete()) amountDeleted++
}
if (deleteCustomCover && deleteCustomCover(manga.id)) {
amountDeleted++
}
return amountDeleted
}
/** /**
* Delete custom cover of the manga from the cache * Delete custom cover of the manga from the cache
* *

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.migration.search package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
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.SourceItem
import eu.kanade.tachiyomi.util.system.getSerializableCompat import eu.kanade.tachiyomi.util.system.getSerializableCompat
class SourceSearchController( class SourceSearchController(
@ -13,30 +15,34 @@ class SourceSearchController(
) : BrowseSourceController(bundle) { ) : BrowseSourceController(bundle) {
constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this( constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this(
Bundle().apply { bundleOf(
putLong(SOURCE_ID_KEY, source.id) SOURCE_ID_KEY to source.id,
putSerializable(MANGA_KEY, manga) MANGA_KEY to manga,
if (searchQuery != null) { SEARCH_QUERY_KEY to searchQuery,
putString(SEARCH_QUERY_KEY, searchQuery) ),
}
},
) )
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY) private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
private var newManga: Manga? = null private var newManga: Manga? = null
override fun onItemClick(view: View, position: Int): Boolean { @Composable
val item = adapter?.getItem(position) as? SourceItem ?: return false override fun ComposeContent() {
newManga = item.manga SourceSearchScreen(
val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController? presenter = presenter,
val dialog = navigateUp = { router.popCurrentController() },
SearchController.MigrationDialog(oldManga, newManga, this) onFabClick = { filterSheet?.show() },
dialog.targetController = searchController onClickManga = {
dialog.showDialog(router) newManga = it
return true 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)
},
)
override fun onItemLongClick(position: Int) { LaunchedEffect(presenter.filters) {
view?.let { super.onItemClick(it, position) } initFilterSheet()
}
} }
} }

View File

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.paging.PagingSource
import androidx.paging.PagingState
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.withIOContext
abstract class BrowsePagingSource : PagingSource<Long, SManga>() {
abstract suspend fun requestNextPage(currentPage: Int): MangasPage
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, SManga> {
val page = params.key ?: 1
val mangasPage = try {
withIOContext {
requestNextPage(page.toInt())
}
} catch (e: Exception) {
return LoadResult.Error(e)
}
return LoadResult.Page(
data = mangasPage.mangas,
prevKey = null,
nextKey = if (mangasPage.hasNextPage) page + 1 else null,
)
}
override fun getRefreshKey(state: PagingState<Long, SManga>): Long? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
}
}
}

View File

@ -1,328 +1,125 @@
package eu.kanade.tachiyomi.ui.browse.source.browse package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import androidx.compose.runtime.Composable
import android.view.Menu import androidx.compose.runtime.LaunchedEffect
import android.view.MenuInflater import androidx.compose.runtime.rememberCoroutineScope
import android.view.MenuItem import androidx.core.os.bundleOf
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.source.model.Source import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R import eu.kanade.presentation.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.tachiyomi.databinding.SourceControllerBinding import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import eu.kanade.tachiyomi.widget.EmptyView
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.LogPriority
import uy.kohesive.injekt.injectLazy
open class BrowseSourceController(bundle: Bundle) : open class BrowseSourceController(bundle: Bundle) :
SearchableNucleusController<SourceControllerBinding, BrowseSourcePresenter>(bundle), FullComposeController<BrowseSourcePresenter>(bundle) {
FabController,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.EndlessScrollListener,
ChangeMangaCategoriesDialog.Listener {
constructor(sourceId: Long, query: String? = null) : this( constructor(sourceId: Long, query: String? = null) : this(
Bundle().apply { bundleOf(
putLong(SOURCE_ID_KEY, sourceId) SOURCE_ID_KEY to sourceId,
query?.let { query -> SEARCH_QUERY_KEY to query,
putString(SEARCH_QUERY_KEY, query) ),
}
},
) )
constructor(source: CatalogueSource, query: String? = null) : this(source.id, query) constructor(source: CatalogueSource, query: String? = null) : this(source.id, query)
constructor(source: Source, query: String? = null) : this(source.id, query) constructor(source: Source, query: String? = null) : this(source.id, query)
private val preferences: PreferencesHelper by injectLazy()
/**
* Adapter containing the list of manga from the catalogue.
*/
protected var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Snackbar containing an error message when a request fails.
*/
private var snack: Snackbar? = null
/** /**
* Sheet containing filter items. * Sheet containing filter items.
*/ */
private var filterSheet: SourceFilterSheet? = null protected var filterSheet: SourceFilterSheet? = null
/** @Composable
* Recycler view with the list of results. override fun ComposeContent() {
*/ val scope = rememberCoroutineScope()
private var recycler: RecyclerView? = null
/** BrowseSourceScreen(
* Subscription for the number of manga per row. presenter = presenter,
*/ navigateUp = { router.popCurrentController() },
private var numColumnsJob: Job? = null onDisplayModeChange = { presenter.displayMode = (it) },
onFabClick = { filterSheet?.show() },
onMangaClick = { router.pushController(MangaController(it.id, true)) },
onMangaLongClick = { manga ->
scope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
when {
manga.favorite -> presenter.dialog = Dialog.RemoveManga(manga)
duplicateManga != null -> presenter.dialog = Dialog.AddDuplicateManga(manga, duplicateManga)
else -> presenter.addFavorite(manga)
}
}
},
)
/** val onDismissRequest = { presenter.dialog = null }
* Endless loading item. when (val dialog = presenter.dialog) {
*/ is Dialog.AddDuplicateManga -> {
private var progressItem: ProgressItem? = null DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { presenter.addFavorite(dialog.manga) },
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
)
}
is Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.changeMangaFavorite(dialog.manga)
},
)
}
is Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.changeMangaFavorite(dialog.manga)
presenter.moveMangaToCategories(dialog.manga, include)
},
)
}
null -> {}
}
init { LaunchedEffect(presenter.filters) {
setHasOptionsMenu(true) initFilterSheet()
} }
override fun getTitle(): String? {
return presenter.source.name
} }
override fun createPresenter(): BrowseSourcePresenter { override fun createPresenter(): BrowseSourcePresenter {
return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY)) return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY))
} }
override fun createBinding(inflater: LayoutInflater) = SourceControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Initialize adapter, scroll listener and recycler views
adapter = FlexibleAdapter(null, this)
setupRecycler(view)
binding.progress.isVisible = true
presenter.restartPager()
}
open fun initFilterSheet() { open fun initFilterSheet() {
if (presenter.sourceFilters.isEmpty()) { if (presenter.filters.isEmpty()) {
return return
} }
filterSheet = SourceFilterSheet( filterSheet = SourceFilterSheet(
activity!!, activity!!,
onFilterClicked = { onFilterClicked = {
showProgressBar() presenter.setSourceFilter(presenter.filters)
adapter?.clear()
presenter.setSourceFilter(presenter.sourceFilters)
}, },
onResetClicked = { onResetClicked = {
presenter.appliedFilters = FilterList() presenter.resetFilter()
val newFilters = presenter.source.getFilterList()
presenter.sourceFilters = newFilters
filterSheet?.setFilters(presenter.filterItems) filterSheet?.setFilters(presenter.filterItems)
}, },
) )
filterSheet?.setFilters(presenter.filterItems) filterSheet?.setFilters(presenter.filterItems)
filterSheet?.setOnShowListener { actionFab?.hide() }
filterSheet?.setOnDismissListener { actionFab?.show() }
actionFab?.setOnClickListener { filterSheet?.show() }
actionFab?.show()
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setText(R.string.action_filter)
fab.setIconResource(R.drawable.ic_filter_list_24dp)
// Controlled by initFilterSheet()
fab.hide()
initFilterSheet()
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
fab.setOnClickListener(null)
actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) }
actionFab = null
}
override fun onDestroyView(view: View) {
numColumnsJob?.cancel()
numColumnsJob = null
adapter = null
snack = null
recycler = null
super.onDestroyView(view)
}
private fun setupRecycler(view: View) {
numColumnsJob?.cancel()
var oldPosition = RecyclerView.NO_POSITION
val oldRecycler = binding.catalogueView.getChildAt(1)
if (oldRecycler is RecyclerView) {
oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
oldRecycler.adapter = null
binding.catalogueView.removeView(oldRecycler)
}
val recycler = if (preferences.sourceDisplayMode().get() == LibraryDisplayMode.List) {
RecyclerView(view.context).apply {
id = R.id.recycler
layoutManager = LinearLayoutManager(context)
layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
} else {
(binding.catalogueView.inflate(R.layout.source_recycler_autofit) as AutofitRecyclerView).apply {
numColumnsJob = getColumnsPreferenceForCurrentOrientation().asHotFlow { spanCount = it }
.drop(1)
// Set again the adapter to recalculate the covers height
.onEach { adapter = this@BrowseSourceController.adapter }
.launchIn(viewScope)
(layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter?.getItemViewType(position)) {
R.layout.source_compact_grid_item, R.layout.source_comfortable_grid_item -> 1
else -> spanCount
}
}
}
}
}
if (filterSheet != null) {
// Add bottom padding if filter FAB is visible
recycler.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.fab_list_padding))
recycler.clipToPadding = false
actionFab?.shrinkOnScroll(recycler)
}
recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
recycler.setHasFixedSize(true)
recycler.adapter = adapter
binding.catalogueView.addView(recycler, 1)
if (oldPosition != RecyclerView.NO_POSITION) {
recycler.layoutManager?.scrollToPosition(oldPosition)
}
this.recycler = recycler
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.source_browse, R.id.action_search)
val searchItem = menu.findItem(R.id.action_search)
searchItem.fixExpand(
onExpand = { invalidateMenuOnExpand() },
onCollapse = {
if (router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController) {
router.popController(this)
} else {
nonSubmittedQuery = ""
searchWithQuery("")
}
true
},
)
val displayItem = when (preferences.sourceDisplayMode().get()) {
LibraryDisplayMode.List -> R.id.action_list
LibraryDisplayMode.ComfortableGrid -> R.id.action_comfortable_grid
else -> R.id.action_compact_grid
}
menu.findItem(displayItem).isChecked = true
}
override fun onSearchViewQueryTextSubmit(query: String?) {
searchWithQuery(query ?: "")
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
val isHttpSource = presenter.source is HttpSource
menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource
val isLocalSource = presenter.source is LocalSource
menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_search -> expandActionViewFromInteraction = true
R.id.action_compact_grid -> setDisplayMode(LibraryDisplayMode.CompactGrid)
R.id.action_comfortable_grid -> setDisplayMode(LibraryDisplayMode.ComfortableGrid)
R.id.action_list -> setDisplayMode(LibraryDisplayMode.List)
R.id.action_open_in_web_view -> openInWebView()
R.id.action_local_source_help -> openLocalSourceHelpGuide()
}
return super.onOptionsItemSelected(item)
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity, source.baseUrl, source.id, presenter.source.name)
startActivity(intent)
}
private fun openLocalSourceHelpGuide() {
activity?.openInBrowser(LocalSource.HELP_URL)
} }
/** /**
@ -331,15 +128,8 @@ open class BrowseSourceController(bundle: Bundle) :
* @param newQuery the new query. * @param newQuery the new query.
*/ */
fun searchWithQuery(newQuery: String) { fun searchWithQuery(newQuery: String) {
// If text didn't change, do nothing presenter.searchQuery = newQuery
if (presenter.query == newQuery) { presenter.search()
return
}
showProgressBar()
adapter?.clear()
presenter.restartPager(newQuery, presenter.sourceFilters)
} }
/** /**
@ -350,7 +140,7 @@ open class BrowseSourceController(bundle: Bundle) :
* @param genreName the name of the genre * @param genreName the name of the genre
*/ */
fun searchWithGenre(genreName: String) { fun searchWithGenre(genreName: String) {
val defaultFilters = presenter.source.getFilterList() val defaultFilters = presenter.source!!.getFilterList()
var genreExists = false var genreExists = false
@ -380,320 +170,15 @@ open class BrowseSourceController(bundle: Bundle) :
} }
if (genreExists) { if (genreExists) {
presenter.sourceFilters = defaultFilters
filterSheet?.setFilters(presenter.filterItems) filterSheet?.setFilters(presenter.filterItems)
showProgressBar() presenter.searchQuery = ""
presenter.setFilter(defaultFilters)
adapter?.clear()
presenter.restartPager("", defaultFilters)
} else { } else {
searchWithQuery(genreName) searchWithQuery(genreName)
} }
} }
/**
* Called from the presenter when the network request is received.
*
* @param page the current page.
* @param mangas the list of manga of the page.
*/
fun onAddPage(page: Int, mangas: List<SourceItem>) {
val adapter = adapter ?: return
hideProgressBar()
if (page == 1) {
adapter.clear()
resetProgressItem()
}
adapter.onLoadMoreComplete(mangas)
}
/**
* Called from the presenter when the network request fails.
*
* @param error the error received.
*/
fun onAddPageError(error: Throwable) {
logcat(LogPriority.ERROR, error)
val adapter = adapter ?: return
adapter.onLoadMoreComplete(null)
hideProgressBar()
snack?.dismiss()
val message = getErrorMessage(error)
val retryAction = View.OnClickListener {
// If not the first page, show bottom progress bar.
if (adapter.mainItemCount > 0 && progressItem != null) {
adapter.addScrollableFooterWithDelay(progressItem!!, 0, true)
} else {
showProgressBar()
}
presenter.requestNext()
}
if (adapter.isEmpty) {
val actions = if (presenter.source is LocalSource) {
listOf(
EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { openLocalSourceHelpGuide() },
)
} else {
listOf(
EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp, retryAction),
EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { openInWebView() },
EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { activity?.openInBrowser(MoreController.URL_HELP) },
)
}
binding.emptyView.show(message, actions)
} else {
snack = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry, retryAction)
}
}
}
private fun getErrorMessage(error: Throwable): String {
if (error is NoResultsException) {
return binding.catalogueView.context.getString(R.string.no_results_found)
}
return when {
error.message == null -> ""
error.message!!.startsWith("HTTP error") -> "${error.message}: ${binding.catalogueView.context.getString(R.string.http_error_hint)}"
else -> error.message!!
}
}
/**
* Sets a new progress item and reenables the scroll listener.
*/
private fun resetProgressItem() {
progressItem = ProgressItem()
adapter?.endlessTargetCount = 0
adapter?.setEndlessScrollListener(this, progressItem!!)
}
/**
* Called by the adapter when scrolled near the bottom.
*/
override fun onLoadMore(lastPosition: Int, currentPage: Int) {
if (presenter.hasNextPage()) {
presenter.requestNext()
} else {
adapter?.onLoadMoreComplete(null)
adapter?.endlessTargetCount = 1
}
}
override fun noMoreLoad(newItemsSize: Int) {
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the manga initialized
*/
fun onMangaInitialized(manga: Manga) {
getHolder(manga)?.setImage(manga)
}
/**
* Sets the current display mode.
*
* @param mode the mode to change to
*/
private fun setDisplayMode(mode: LibraryDisplayMode) {
val view = view ?: return
val adapter = adapter ?: return
preferences.sourceDisplayMode().set(mode)
activity?.invalidateOptionsMenu()
setupRecycler(view)
// Initialize mangas if not on a metered connection
if (!view.context.connectivityManager.isActiveNetworkMetered) {
val mangas = (0 until adapter.itemCount).mapNotNull {
(adapter.getItem(it) as? SourceItem)?.manga
}
presenter.initializeMangas(mangas)
}
}
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
/**
* 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): SourceHolder<*>? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition) as? SourceItem
if (item != null && item.manga.id == manga.id) {
return holder as SourceHolder<*>
}
}
return null
}
/**
* Shows the progress bar.
*/
private fun showProgressBar() {
binding.emptyView.hide()
binding.progress.isVisible = true
snack?.dismiss()
snack = null
}
/**
* Hides active progress bars.
*/
private fun hideProgressBar() {
binding.emptyView.hide()
binding.progress.isVisible = false
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? SourceItem ?: return false
router.pushController(MangaController(item.manga.id, true))
return false
}
/**
* Called when a manga is long clicked.
*
* Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga
* in, the list consists of the default category plus the user's categories. The default category is preselected on
* new manga, and on already favorited manga the manga's categories are preselected.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
val activity = activity ?: return
val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return
viewScope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
withUIContext {
if (manga.favorite) {
MaterialAlertDialogBuilder(activity)
.setTitle(manga.title)
.setItems(arrayOf(activity.getString(R.string.remove_from_library))) { _, which ->
when (which) {
0 -> {
presenter.changeMangaFavorite(manga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_removed_library))
}
}
}
.show()
} else {
if (duplicateManga != null) {
AddDuplicateMangaDialog(this@BrowseSourceController, duplicateManga) {
addToLibrary(
manga,
position,
)
}
.showDialog(router)
} else {
addToLibrary(manga, position)
}
}
}
}
}
private fun addToLibrary(newManga: Manga, position: Int) {
val activity = activity ?: return
viewScope.launchIO {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
withUIContext {
when {
// Default category set
defaultCategory != null -> {
presenter.moveMangaToCategory(newManga.toDbManga(), defaultCategory)
presenter.changeMangaFavorite(newManga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
presenter.moveMangaToCategory(newManga.toDbManga(), null)
presenter.changeMangaFavorite(newManga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(newManga)
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this@BrowseSourceController, listOf(newManga), categories, preselected)
.showDialog(router)
}
}
}
}
}
/**
* Update manga to use selected categories.
*
* @param mangas The list of manga to move to categories.
* @param categories The list of categories where manga will be placed.
*/
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
presenter.changeMangaFavorite(manga.toDbManga())
presenter.updateMangaCategories(manga.toDbManga(), addCategories)
val position = adapter?.currentItems?.indexOfFirst { it -> (it as SourceItem).manga.id == manga.id }
if (position != null) {
adapter?.notifyItemChanged(position)
}
activity?.toast(activity?.getString(R.string.manga_added_library))
}
protected companion object { protected companion object {
const val SOURCE_ID_KEY = "sourceId" const val SOURCE_ID_KEY = "sourceId"
const val SEARCH_QUERY_KEY = "searchQuery" const val SEARCH_QUERY_KEY = "searchQuery"

View File

@ -1,7 +1,25 @@
package eu.kanade.tachiyomi.ui.browse.source.browse package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.cachedIn
import androidx.paging.map
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.mapAsCheckboxState
import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.SetMangaCategories import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
@ -14,6 +32,8 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.model.toMangaUpdate import eu.kanade.domain.manga.model.toMangaUpdate
import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.presentation.browse.BrowseSourceState
import eu.kanade.presentation.browse.BrowseSourceStateImpl
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.data.database.models.toDomainManga
@ -22,6 +42,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@ -42,19 +63,17 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -65,6 +84,7 @@ import eu.kanade.domain.manga.model.Manga as DomainManga
open class BrowseSourcePresenter( open class BrowseSourcePresenter(
private val sourceId: Long, private val sourceId: Long,
searchQuery: String? = null, searchQuery: String? = null,
private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl,
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(), private val coverCache: CoverCache = Injekt.get(),
@ -77,55 +97,76 @@ open class BrowseSourcePresenter(
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(), private val insertTrack: InsertTrack = Injekt.get(),
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(), private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
) : BasePresenter<BrowseSourceController>() { ) : BasePresenter<BrowseSourceController>(), BrowseSourceState by state {
/** var displayMode by preferences.sourceDisplayMode().asState()
* Selected source.
*/
lateinit var source: CatalogueSource
/** @Composable
* Modifiable list of filters. fun getColumnsPreferenceForCurrentOrientation(): State<GridCells> {
*/ val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
var sourceFilters = FilterList() return produceState<GridCells>(initialValue = GridCells.Adaptive(128.dp), isLandscape) {
set(value) { (if (isLandscape) preferences.landscapeColumns() else preferences.portraitColumns())
field = value .asFlow()
filterItems = value.toItems() .collectLatest { columns ->
value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns)
}
} }
}
var filterItems: List<IFlexible<*>> = emptyList() @Composable
fun getMangaList(): Flow<PagingData<DomainManga>> {
return remember(currentQuery, appliedFilters) {
Pager(
PagingConfig(pageSize = 25),
) {
createPager(currentQuery, appliedFilters)
}.flow
.map {
it.map {
withIOContext {
networkToLocalManga(it, sourceId).toDomainManga()!!
}
}
}
.cachedIn(presenterScope)
}
}
/** @Composable
* List of filters used by the [Pager]. If empty alongside [query], the popular query is used. fun getManga(initialManga: DomainManga): State<DomainManga> {
*/ return produceState(initialValue = initialManga, initialManga.url, initialManga.source) {
var appliedFilters = FilterList() getManga.subscribe(initialManga.url, initialManga.source)
.collectLatest { manga ->
if (manga == null) return@collectLatest
launchIO {
initializeMangas(manga)
}
value = manga
}
}
}
/** fun setFilter(filters: FilterList) {
* Pager containing a list of manga results. state.filters = filters
*/ }
private lateinit var pager: Pager
/** fun resetFilter() {
* Subscription for the pager. state.appliedFilters = FilterList()
*/ val newFilters = source!!.getFilterList()
private var pagerJob: Job? = null state.filters = newFilters
}
/** fun search() {
* Subscription for one request from the pager. state.currentQuery = searchQuery ?: ""
*/ }
private var nextPageJob: Job? = null
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } } private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
init {
query = searchQuery ?: ""
}
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
source = sourceManager.get(sourceId) as? CatalogueSource ?: return state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return
sourceFilters = source.getFilterList() state.filters = source!!.getFilterList()
if (savedState != null) { if (savedState != null) {
query = savedState.getString(::query.name, "") query = savedState.getString(::query.name, "")
@ -137,79 +178,6 @@ open class BrowseSourcePresenter(
super.onSave(state) super.onSave(state)
} }
/**
* Restarts the pager for the active source with the provided query and filters.
*
* @param query the query.
* @param filters the current state of the filters (for search mode).
*/
fun restartPager(query: String = this.query, filters: FilterList = this.appliedFilters) {
this.query = query
this.appliedFilters = filters
// Create a new pager.
pager = createPager(query, filters)
val sourceId = source.id
val sourceDisplayMode = preferences.sourceDisplayMode()
pagerJob?.cancel()
pagerJob = presenterScope.launchIO {
pager.asFlow()
.map { (first, second) ->
first to second.map {
networkToLocalManga(
it,
sourceId,
).toDomainManga()!!
}
}
.onEach { initializeMangas(it.second) }
.map { (first, second) ->
first to second.map {
SourceItem(
it,
sourceDisplayMode,
)
}
}
.catch { error ->
logcat(LogPriority.ERROR, error)
}
.collectLatest { (page, mangas) ->
withUIContext {
view?.onAddPage(page, mangas)
}
}
}
// Request first page.
requestNext()
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (!hasNextPage()) return
nextPageJob?.cancel()
nextPageJob = presenterScope.launchIO {
try {
pager.requestNextPage()
} catch (e: Throwable) {
withUIContext { view?.onAddPageError(e) }
}
}
}
/**
* Returns true if the last fetched page has a next page.
*/
fun hasNextPage(): Boolean {
return pager.hasNextPage
}
/** /**
* Returns a manga from the database for the given manga from network. It creates a new entry * 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. * if the manga is not yet in the database.
@ -217,16 +185,14 @@ open class BrowseSourcePresenter(
* @param sManga the manga from the source. * @param sManga the manga from the source.
* @return a manga from the database. * @return a manga from the database.
*/ */
private fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = runBlocking { getManga.await(sManga.url, sourceId) } var localManga = getManga.await(sManga.url, sourceId)
if (localManga == null) { if (localManga == null) {
val newManga = Manga.create(sManga.url, sManga.title, sourceId) val newManga = Manga.create(sManga.url, sManga.title, sourceId)
newManga.copyFrom(sManga) newManga.copyFrom(sManga)
newManga.id = -1 newManga.id = -1
val result = runBlocking { val id = insertManga.await(newManga.toDomainManga()!!)
val id = insertManga.await(newManga.toDomainManga()!!) val result = getManga.await(id!!)
getManga.await(id!!)
}
localManga = result localManga = result
} else if (!localManga.favorite) { } else if (!localManga.favorite) {
// if the manga isn't a favorite, set its display title from source // if the manga isn't a favorite, set its display title from source
@ -237,146 +203,123 @@ open class BrowseSourcePresenter(
} }
/** /**
* Initialize a list of manga. * Initialize a manga.
* *
* @param mangas the list of manga to initialize. * @param mangas the list of manga to initialize.
*/ */
fun initializeMangas(mangas: List<DomainManga>) { private suspend fun initializeMangas(manga: DomainManga) {
presenterScope.launchIO { if (manga.thumbnailUrl != null && manga.initialized) return
mangas.asFlow() withContext(NonCancellable) {
.filter { it.thumbnailUrl == null && !it.initialized } val db = manga.toDbManga()
.map { getMangaDetails(it.toDbManga()) } try {
.onEach { val networkManga = source!!.getMangaDetails(db.copy())
withUIContext { db.copyFrom(networkManga)
@Suppress("DEPRECATION") db.initialized = true
view?.onMangaInitialized(it.toDomainManga()!!) updateManga.await(
} db
} .toDomainManga()
.catch { e -> logcat(LogPriority.ERROR, e) } ?.toMangaUpdate()!!,
.collect() )
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
} }
} }
/**
* Returns the initialized manga.
*
* @param manga the manga to initialize.
* @return the initialized manga
*/
private suspend fun getMangaDetails(manga: Manga): Manga {
try {
val networkManga = source.getMangaDetails(manga.copy())
manga.copyFrom(networkManga)
manga.initialized = true
updateManga.await(
manga
.toDomainManga()
?.toMangaUpdate()!!,
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
return manga
}
/** /**
* Adds or removes a manga from the library. * Adds or removes a manga from the library.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun changeMangaFavorite(manga: Manga) { fun changeMangaFavorite(manga: DomainManga) {
manga.favorite = !manga.favorite presenterScope.launch {
manga.date_added = when (manga.favorite) { var new = manga.copy(
true -> Date().time favorite = !manga.favorite,
false -> 0 dateAdded = when (manga.favorite) {
} true -> Date().time
false -> 0
if (!manga.favorite) { },
manga.removeCovers(coverCache)
} else {
ChapterSettingsHelper.applySettingDefaults(manga.toDomainManga()!!)
autoAddTrack(manga)
}
runBlocking {
updateManga.await(
manga
.toDomainManga()
?.toMangaUpdate()!!,
) )
if (!new.favorite) {
new = new.removeCovers(coverCache)
} else {
ChapterSettingsHelper.applySettingDefaults(manga)
autoAddTrack(manga)
}
updateManga.await(new.toMangaUpdate())
} }
} }
private fun autoAddTrack(manga: Manga) { fun getSourceOrStub(manga: DomainManga): Source {
launchIO { return sourceManager.getOrStub(manga.source)
loggedServices }
.filterIsInstance<EnhancedTrackService>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id!!
(service as TrackService).bind(track)
insertTrack.await(track.toDomainTrack()!!)
val chapters = getChapterByMangaId.await(manga.id!!) fun addFavorite(manga: DomainManga) {
syncChaptersWithTrackServiceTwoWay.await(chapters, track.toDomainTrack()!!, service) presenterScope.launch {
} val categories = getCategories()
} catch (e: Exception) { val defaultCategoryId = preferences.defaultCategory()
logcat(LogPriority.WARN, e) { "Could not match manga: ${manga.title} with service $service" } val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
}
when {
// Default category set
defaultCategory != null -> {
moveMangaToCategories(manga, defaultCategory)
changeMangaFavorite(manga)
// activity.toast(activity.getString(R.string.manga_added_library))
} }
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
moveMangaToCategories(manga)
changeMangaFavorite(manga)
// activity.toast(activity.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id }
state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds })
}
}
} }
} }
private suspend fun autoAddTrack(manga: DomainManga) {
loggedServices
.filterIsInstance<EnhancedTrackService>()
.filter { it.accept(source!!) }
.forEach { service ->
try {
service.match(manga.toDbManga())?.let { track ->
track.manga_id = manga.id
(service as TrackService).bind(track)
insertTrack.await(track.toDomainTrack()!!)
val chapters = getChapterByMangaId.await(manga.id)
syncChaptersWithTrackServiceTwoWay.await(chapters, track.toDomainTrack()!!, service)
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Could not match manga: ${manga.title} with service $service" }
}
}
}
/** /**
* Set the filter states for the current source. * Set the filter states for the current source.
* *
* @param filters a list of active filters. * @param filters a list of active filters.
*/ */
fun setSourceFilter(filters: FilterList) { fun setSourceFilter(filters: FilterList) {
restartPager(filters = filters) state.appliedFilters = filters
} }
open fun createPager(query: String, filters: FilterList): Pager { open fun createPager(query: String, filters: FilterList): PagingSource<Long, SManga> {
return SourcePager(source, query, filters) return SourceBrowsePagingSource(source!!, query, filters)
}
private fun FilterList.toItems(): List<IFlexible<*>> {
return mapNotNull { filter ->
when (filter) {
is Filter.Header -> HeaderItem(filter)
is Filter.Separator -> SeparatorItem(filter)
is Filter.CheckBox -> CheckboxItem(filter)
is Filter.TriState -> TriStateItem(filter)
is Filter.Text -> TextItem(filter)
is Filter.Select<*> -> SelectItem(filter)
is Filter.Group<*> -> {
val group = GroupItem(filter)
val subItems = filter.state.mapNotNull {
when (it) {
is Filter.CheckBox -> CheckboxSectionItem(it)
is Filter.TriState -> TriStateSectionItem(it)
is Filter.Text -> TextSectionItem(it)
is Filter.Select<*> -> SelectSectionItem(it)
else -> null
}
}
subItems.forEach { it.header = group }
group.subItems = subItems
group
}
is Filter.Sort -> {
val group = SortGroup(filter)
val subItems = filter.values.map {
SortItem(it, group)
}
group.subItems = subItems
group
}
}
}
} }
/** /**
@ -395,54 +338,67 @@ open class BrowseSourcePresenter(
return getDuplicateLibraryManga.await(manga.title, manga.source) return getDuplicateLibraryManga.await(manga.title, manga.source)
} }
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: DomainManga): Array<Long?> {
return runBlocking { getCategories.await(manga.id) }
.map { it.id }
.toTypedArray()
}
/** /**
* Move the given manga to categories. * Move the given manga to categories.
* *
* @param categories the selected categories. * @param categories the selected categories.
* @param manga the manga to move. * @param manga the manga to move.
*/ */
private fun moveMangaToCategories(manga: Manga, categories: List<DomainCategory>) { fun moveMangaToCategories(manga: DomainManga, vararg categories: DomainCategory) {
moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id })
}
fun moveMangaToCategories(manga: DomainManga, categoryIds: List<Long>) {
presenterScope.launchIO { presenterScope.launchIO {
setMangaCategories.await( setMangaCategories.await(
mangaId = manga.id!!, mangaId = manga.id,
categoryIds = categories.filter { it.id != 0L }.map { it.id }, categoryIds = categoryIds.toList(),
) )
} }
} }
/** sealed class Dialog {
* Move the given manga to the category. data class RemoveManga(val manga: DomainManga) : Dialog()
* data class AddDuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog()
* @param category the selected category. data class ChangeMangaCategory(
* @param manga the manga to move. val manga: DomainManga,
*/ val initialSelection: List<CheckboxState.State<DomainCategory>>,
fun moveMangaToCategory(manga: Manga, category: DomainCategory?) { ) : Dialog()
moveMangaToCategories(manga, listOfNotNull(category)) }
} }
/** fun FilterList.toItems(): List<IFlexible<*>> {
* Update manga to use selected categories. return mapNotNull { filter ->
* when (filter) {
* @param manga needed to change is Filter.Header -> HeaderItem(filter)
* @param selectedCategories selected categories is Filter.Separator -> SeparatorItem(filter)
*/ is Filter.CheckBox -> CheckboxItem(filter)
fun updateMangaCategories(manga: Manga, selectedCategories: List<DomainCategory>) { is Filter.TriState -> TriStateItem(filter)
if (!manga.favorite) { is Filter.Text -> TextItem(filter)
changeMangaFavorite(manga) is Filter.Select<*> -> SelectItem(filter)
} is Filter.Group<*> -> {
val group = GroupItem(filter)
moveMangaToCategories(manga, selectedCategories) val subItems = filter.state.mapNotNull {
when (it) {
is Filter.CheckBox -> CheckboxSectionItem(it)
is Filter.TriState -> TriStateSectionItem(it)
is Filter.Text -> TextSectionItem(it)
is Filter.Select<*> -> SelectSectionItem(it)
else -> null
}
}
subItems.forEach { it.header = group }
group.subItems = subItems
group
}
is Filter.Sort -> {
val group = SortGroup(filter)
val subItems = filter.values.map {
SortItem(it, group)
}
group.subItems = subItems
group
}
}
} }
} }

View File

@ -1,31 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.flow.Flow
/**
* A general pager for source requests (latest updates, popular, search)
*/
abstract class Pager(var currentPage: Int = 1) {
var hasNextPage = true
private set
protected val results: PublishRelay<Pair<Int, List<SManga>>> = PublishRelay.create()
fun asFlow(): Flow<Pair<Int, List<SManga>>> {
return results.asObservable().asFlow()
}
abstract suspend fun requestNextPage()
fun onPageReceived(mangasPage: MangasPage) {
val page = currentPage
currentPage++
hasNextPage = mangasPage.hasNextPage && mangasPage.mangas.isNotEmpty()
results.call(Pair(page, mangasPage.mangas))
}
}

View File

@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
class ProgressItem : AbstractFlexibleItem<ProgressItem.Holder>() {
private var loadMore = true
override fun getLayoutRes(): Int {
return R.layout.source_progress_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: Holder, position: Int, payloads: List<Any?>) {
holder.progressBar.isVisible = false
holder.progressMessage.isVisible = false
if (!adapter.isEndlessScrollEnabled) {
loadMore = false
}
if (loadMore) {
holder.progressBar.isVisible = true
} else {
holder.progressMessage.isVisible = true
}
}
override fun equals(other: Any?): Boolean {
return this === other
}
override fun hashCode(): Int {
return loadMore.hashCode()
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
val progressBar: ProgressBar = view.findViewById(R.id.progress_bar)
val progressMessage: TextView = view.findViewById(R.id.progress_message)
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.util.lang.awaitSingle
class SourceBrowsePagingSource(val source: CatalogueSource, val query: String, val filters: FilterList) : BrowsePagingSource() {
override suspend fun requestNextPage(currentPage: Int): MangasPage {
val observable = if (query.isBlank() && filters.isEmpty()) {
source.fetchPopularManga(currentPage)
} else {
source.fetchSearchManga(currentPage, query, filters)
}
return observable.awaitSingle()
.takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException()
}
}

View File

@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceComfortableGridHolder(
override val binding: SourceComfortableGridItemBinding,
adapter: FlexibleAdapter<*>,
) : SourceHolder<SourceComfortableGridItemBinding>(binding.root, adapter) {
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Set favorite badge
binding.badges.favoriteText.isVisible = manga.favorite
setImage(manga)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceCompactGridHolder(
override val binding: SourceCompactGridItemBinding,
adapter: FlexibleAdapter<*>,
) : SourceHolder<SourceCompactGridItemBinding>(binding.root, adapter) {
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Set favorite badge
binding.badges.favoriteText.isVisible = manga.favorite
setImage(manga)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -1,35 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.viewbinding.ViewBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.domain.manga.model.Manga
/**
* Generic class used to hold the displayed data of a manga in the catalogue.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
*/
abstract class SourceHolder<VB : ViewBinding>(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
abstract val binding: VB
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
abstract fun onSetValues(manga: Manga)
/**
* Updates the image for this holder. Useful to update the image when the manga is initialized
* and the url is now known.
*
* @param manga the manga to bind.
*/
abstract fun setImage(manga: Manga)
}

View File

@ -1,63 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
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
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
class SourceItem(val manga: Manga, private val displayMode: Preference<LibraryDisplayMode>) :
AbstractFlexibleItem<SourceHolder<*>>() {
override fun getLayoutRes(): Int {
return when (displayMode.get()) {
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> R.layout.source_compact_grid_item
LibraryDisplayMode.ComfortableGrid -> R.layout.source_comfortable_grid_item
LibraryDisplayMode.List -> R.layout.source_list_item
}
}
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
): SourceHolder<*> {
return when (displayMode.get()) {
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
SourceCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter)
}
LibraryDisplayMode.ComfortableGrid -> {
SourceComfortableGridHolder(SourceComfortableGridItemBinding.bind(view), adapter)
}
LibraryDisplayMode.List -> {
SourceListHolder(view, adapter)
}
}
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceHolder<*>,
position: Int,
payloads: List<Any?>?,
) {
holder.onSetValues(manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is SourceItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id.hashCode()
}
}

View File

@ -1,60 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
import eu.kanade.tachiyomi.util.system.getResourceColor
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_catalogue_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
SourceHolder<SourceListItemBinding>(view, adapter) {
override val binding = SourceListItemBinding.bind(view)
private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f)
private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface)
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
binding.title.text = manga.title
binding.title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor)
// Set alpha of thumbnail.
binding.thumbnail.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)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
if (!manga.thumbnailUrl.isNullOrEmpty()) {
binding.thumbnail.load(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}
}

View File

@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.lang.awaitSingle
class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() {
override suspend fun requestNextPage() {
val page = currentPage
val observable = if (query.isBlank() && filters.isEmpty()) {
source.fetchPopularManga(page)
} else {
source.fetchSearchManga(page, query, filters)
}
val mangasPage = observable.awaitSingle()
if (mangasPage.mangas.isNotEmpty()) {
onPageReceived(mangasPage)
} else {
throw NoResultsException()
}
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowsePagingSource
import eu.kanade.tachiyomi.util.lang.awaitSingle
class LatestUpdatesBrowsePagingSource(val source: CatalogueSource) : BrowsePagingSource() {
override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchLatestUpdates(currentPage).awaitSingle()
}
}

View File

@ -1,12 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.source.latest package eu.kanade.tachiyomi.ui.browse.source.latest
import android.os.Bundle import android.os.Bundle
import android.view.Menu import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R import eu.kanade.presentation.browse.BrowseLatestScreen
import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.tachiyomi.ui.base.controller.pushController
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.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
/** /**
* Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController]. * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController].
@ -21,9 +29,63 @@ class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY)) return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY))
} }
override fun onPrepareOptionsMenu(menu: Menu) { @Composable
super.onPrepareOptionsMenu(menu) override fun ComposeContent() {
menu.findItem(R.id.action_search).isVisible = false val scope = rememberCoroutineScope()
BrowseLatestScreen(
presenter = presenter,
navigateUp = { router.popCurrentController() },
onMangaClick = { router.pushController(MangaController(it.id, true)) },
onMangaLongClick = { manga ->
scope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
when {
manga.favorite -> presenter.dialog = BrowseSourcePresenter.Dialog.RemoveManga(manga)
duplicateManga != null -> presenter.dialog = BrowseSourcePresenter.Dialog.AddDuplicateManga(manga, duplicateManga)
else -> presenter.addFavorite(manga)
}
}
},
)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is BrowseSourcePresenter.Dialog.AddDuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onOpenManga = {
router.pushController(MangaController(dialog.duplicate.id, true))
},
onConfirm = {
presenter.addFavorite(dialog.manga)
},
duplicateFrom = presenter.getSourceOrStub(dialog.manga),
)
}
is BrowseSourcePresenter.Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.changeMangaFavorite(dialog.manga)
},
)
}
is BrowseSourcePresenter.Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.changeMangaFavorite(dialog.manga)
presenter.moveMangaToCategories(dialog.manga, include)
},
)
}
null -> {}
}
} }
override fun initFilterSheet() { override fun initFilterSheet() {

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
import eu.kanade.tachiyomi.util.lang.awaitSingle
class LatestUpdatesPager(val source: CatalogueSource) : Pager() {
override suspend fun requestNextPage() {
val mangasPage = source.fetchLatestUpdates(currentPage).awaitSingle()
onPageReceived(mangasPage)
}
}

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.source.latest package eu.kanade.tachiyomi.ui.browse.source.latest
import androidx.paging.PagingSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) { class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) {
override fun createPager(query: String, filters: FilterList): Pager { override fun createPager(query: String, filters: FilterList): PagingSource<Long, SManga> {
return LatestUpdatesPager(source) return LatestUpdatesBrowsePagingSource(source!!)
} }
} }

View File

@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.category.visualName
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import eu.kanade.tachiyomi.widget.materialdialogs.setQuadStateMultiChoiceItems
class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener {
private var mangas = emptyList<Manga>()
private var categories = emptyList<Category>()
private var preselected = emptyArray<Int>()
private var selected = emptyArray<Int>().toIntArray()
constructor(
target: T,
mangas: List<Manga>,
categories: List<Category>,
preselected: Array<Int>,
) : this() {
this.mangas = mangas
this.categories = categories
this.preselected = preselected
this.selected = preselected.toIntArray()
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_move_category)
.setNegativeButton(android.R.string.cancel, null)
.apply {
if (categories.isNotEmpty()) {
setQuadStateMultiChoiceItems(
items = categories.map { it.visualName(context) },
isActionList = false,
initialSelected = preselected.toIntArray(),
) { selections ->
selected = selections
}
setPositiveButton(android.R.string.ok) { _, _ ->
val add = selected
.mapIndexed { index, value -> if (value == QuadStateTextView.State.CHECKED.ordinal) categories[index] else null }
.filterNotNull()
val remove = selected
.mapIndexed { index, value -> if (value == QuadStateTextView.State.UNCHECKED.ordinal) categories[index] else null }
.filterNotNull()
(targetController as? Listener)?.updateCategoriesForMangas(mangas, add, remove)
}
setNeutralButton(R.string.action_edit) { _, _ -> openCategoryController() }
} else {
setMessage(R.string.information_empty_category_dialog)
setPositiveButton(R.string.action_edit_categories) { _, _ -> openCategoryController() }
}
}
.create()
}
private fun openCategoryController() {
if (targetController is LibraryController) {
val libController = targetController as LibraryController
libController.clearSelection()
}
router.popCurrentController()
router.pushController(CategoryController())
}
interface Listener {
fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category> = emptyList<Category>())
}
}

View File

@ -1,48 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import uy.kohesive.injekt.injectLazy
class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle) {
private val sourceManager: SourceManager by injectLazy()
private lateinit var libraryManga: Manga
private lateinit var onAddToLibrary: () -> Unit
constructor(
target: Controller,
libraryManga: Manga,
onAddToLibrary: () -> Unit,
) : this() {
targetController = target
this.libraryManga = libraryManga
this.onAddToLibrary = onAddToLibrary
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val source = sourceManager.getOrStub(libraryManga.source)
return MaterialAlertDialogBuilder(activity!!)
.setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name))
.setPositiveButton(activity?.getString(R.string.action_add)) { _, _ ->
onAddToLibrary()
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
dismissDialog()
router.pushController(MangaController(libraryManga.id))
}
.setCancelable(true)
.create()
}
}

View File

@ -22,12 +22,12 @@ import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.components.ChangeCategoryDialog import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.ChapterDownloadAction import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
import eu.kanade.presentation.manga.components.DuplicateMangaDialog
import eu.kanade.presentation.util.calculateWindowWidthSizeClass import eu.kanade.presentation.util.calculateWindowWidthSizeClass
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.DownloadService

View File

@ -51,6 +51,12 @@ fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
return coverCache.deleteFromCache(this, true) return coverCache.deleteFromCache(this, true)
} }
fun DomainManga.removeCovers(coverCache: CoverCache = Injekt.get()): DomainManga {
if (isLocal()) return this
coverCache.deleteFromCache(this, true)
return copy(coverLastModified = Date().time)
}
fun DomainManga.shouldDownloadNewChapters(dbCategories: List<Long>, preferences: PreferencesHelper): Boolean { fun DomainManga.shouldDownloadNewChapters(dbCategories: List<Long>, preferences: PreferencesHelper): Boolean {
if (!favorite) return false if (!favorite) return false

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true" android:color="?attr/colorOnPrimary" />
<item android:state_activated="true" android:color="?attr/colorOnPrimary" />
<item android:color="?android:attr/textColorPrimary" />
</selector>

View File

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="@drawable/library_item_selector"
android:foreground="@drawable/library_item_selector_overlay"
android:padding="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="w,3:2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_launcher" />
<include
android:id="@+id/badges"
layout="@layout/source_grid_item_badges"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="@+id/thumbnail"
app:layout_constraintStart_toStartOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="@+id/thumbnail" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:padding="4dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/source_comfortable_item_title"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/thumbnail"
tools:text="Sample name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="@drawable/library_item_selector"
android:foreground="@drawable/library_item_selector_overlay"
android:padding="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:foreground="@drawable/card_gradient_shape"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="w,2:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_launcher" />
<include
android:id="@+id/badges"
layout="@layout/source_grid_item_badges"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="@+id/thumbnail"
app:layout_constraintStart_toStartOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="@+id/thumbnail" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:ellipsize="end"
android:maxLines="2"
android:padding="8dp"
android:shadowColor="@color/md_black_1000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="4"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/md_white_1000"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/thumbnail"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Sample name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,38 +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="match_parent">
<LinearLayout
android:id="@+id/catalogue_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<FrameLayout
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</LinearLayout>
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -1,116 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp">
<LinearLayout
android:id="@+id/left_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rectangle">
<TextView
android:id="@+id/local_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:text="@string/local_source_badge"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/download_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="120"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_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-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondary"
android:visibility="gone"
tools:text="120"
tools:visibility="visible" />
<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>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/right_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rectangle">
<TextView
android:id="@+id/language_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="EN"
tools:visibility="visible" />
</LinearLayout>
</LinearLayout>

View File

@ -1,138 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_gravity="center_vertical"
android:background="@drawable/list_item_selector_background"
android:paddingHorizontal="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="center_vertical"
android:padding="8dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/badges"
app:layout_constraintHorizontal_bias="0.007"
app:layout_constraintStart_toEndOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.523"
tools:text="Manga title" />
<LinearLayout
android:id="@+id/badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/rounded_rectangle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/local_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:text="@string/local_source_badge"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/download_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="122"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_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-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondary"
android:visibility="gone"
tools:text="130"
tools:visibility="visible" />
<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" />
<TextView
android:id="@+id/language_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
tools:text="EN"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:padding="8dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
app:indicatorSize="24dp"
app:trackThickness="3dp" />
<TextView
android:id="@+id/progress_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_more_results"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.AutofitRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/source_grid"
style="@style/Widget.Tachiyomi.GridView.Source"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:columnWidth="140dp"
android:padding="5dp"
tools:listitem="@layout/source_compact_grid_item" />

View File

@ -1,46 +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" />
<item
android:icon="@drawable/ic_view_module_24dp"
android:title="@string/action_display_mode"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/action_compact_grid"
android:title="@string/action_display_grid" />
<item
android:id="@+id/action_comfortable_grid"
android:title="@string/action_display_comfortable_grid" />
<item
android:id="@+id/action_list"
android:title="@string/action_display_list" />
</group>
</menu>
</item>
<item
android:id="@+id/action_open_in_web_view"
android:icon="@drawable/ic_public_24dp"
android:title="@string/action_open_in_web_view"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_local_source_help"
android:icon="@drawable/ic_help_24dp"
android:title="@string/local_source_help_guide"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
</menu>

View File

@ -870,4 +870,5 @@
<!-- App widget --> <!-- App widget -->
<string name="appwidget_updates_description">See your recently updated manga</string> <string name="appwidget_updates_description">See your recently updated manga</string>
<string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string> <string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
<string name="remove_manga">You are about to remove this manga from your library</string>
</resources> </resources>