diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt index 8aee4b632248..8033c3833771 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -42,6 +42,8 @@ fun LibraryToolbar( // SY --> onClickSyncExh: (() -> Unit)?, isSyncEnabled: Boolean, + onClickTrackerManga: () -> Unit, + hasLoggedInTrackers: Boolean, // SY <-- searchQuery: String?, onSearchQueryChange: (String?) -> Unit, @@ -66,6 +68,8 @@ fun LibraryToolbar( // SY --> onClickSyncExh = onClickSyncExh, isSyncEnabled = isSyncEnabled, + onClickTrackerManga = onClickTrackerManga, + hasLoggedInTrackers = hasLoggedInTrackers, // SY <-- scrollBehavior = scrollBehavior, ) @@ -85,6 +89,8 @@ private fun LibraryRegularToolbar( // SY --> onClickSyncExh: (() -> Unit)?, isSyncEnabled: Boolean, + onClickTrackerManga: () -> Unit, + hasLoggedInTrackers: Boolean, // SY <-- scrollBehavior: TopAppBarScrollBehavior?, ) { @@ -149,6 +155,14 @@ private fun LibraryRegularToolbar( ), ) } + if (hasLoggedInTrackers) { + add( + AppBar.OverflowAction( + title = "Tracker Manga", + onClick = onClickTrackerManga, + ), + ) + } // SY <-- }.build(), ) diff --git a/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreen.kt b/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreen.kt new file mode 100644 index 000000000000..42f34ba65edb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreen.kt @@ -0,0 +1,291 @@ +package eu.kanade.presentation.library.tracker + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.library.tracker.components.TrackStatusTabs +import eu.kanade.presentation.library.tracker.components.mangaListItem +import eu.kanade.presentation.track.components.TrackLogoIcon +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.data.track.Tracker +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.launch +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import tachiyomi.i18n.sy.SYMR +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.screens.LoadingScreen + +class TrackerMangaListScreen : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + + val screenModel = rememberScreenModel { TrackerMangaListScreenModel() } + val state by screenModel.state.collectAsState() + val scope = rememberCoroutineScope() + val scrollStates = remember { + mutableStateMapOf>() + } + + Scaffold( + topBar = { scrollBehaviour -> + TrackerMangaListAppBar(screenModel.getTrackerName(), scrollBehaviour, navigator::pop, screenModel::toggleTrackerSelectDialog) + }, + ) { contentPadding -> + when { + state.statusList.isEmpty() -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + + else -> { + val pagerState = rememberPagerState( + initialPage = state.currentTabIndex.coerceIn(0, state.statusList.lastIndex), + pageCount = { state.statusList.size }, + ) + + LaunchedEffect(state.trackerId) { + pagerState.scrollToPage(0) + } + + Column(modifier = Modifier.padding(contentPadding)) { + TrackStatusTabs( + statusList = state.statusList, + getStatusRes = state.getStatusRes, + pagerState = pagerState, + ) { index -> + scope.launch { + pagerState.animateScrollToPage(index) + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val currentTabState = state.tabs[page] ?: TabMangaList() + + val currentScrollState = remember(page, state.trackerId) { + LazyListState( + firstVisibleItemIndex = scrollStates[page]?.first ?: 0, + firstVisibleItemScrollOffset = scrollStates[page]?.second ?: 0, + ) + } + + LaunchedEffect(page, currentScrollState, state.trackerId) { + snapshotFlow { + val layoutInfo = currentScrollState.layoutInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible >= layoutInfo.totalItemsCount - 20 + }.collect { shouldLoadMore -> + if (shouldLoadMore) { + try { + screenModel.loadNextPage(page) + } catch (e: Exception) { + context.toast( + context.stringResource( + MR.strings.track_error, + screenModel.getTrackerName(), + e.message ?: "", + ), + ) + } + } + } + } + + LaunchedEffect(page) { + snapshotFlow { + currentScrollState.firstVisibleItemIndex to currentScrollState.firstVisibleItemScrollOffset + } + .collect { (index, offset) -> + scrollStates[page] = index to offset + } + try { + screenModel.changeTab(page) + } catch (e: Exception) { + context.toast( + context.stringResource( + MR.strings.track_error, + screenModel.getTrackerName(), + e.message ?: "", + ), + ) + } + } + + val isFirstLoad = currentTabState.isLoading && currentTabState.items.isEmpty() + if (isFirstLoad) { + LoadingScreen(modifier = Modifier.fillMaxWidth()) + return@HorizontalPager + } + + if (currentTabState.items.isEmpty()) { + EmptyScreen( + message = "All entries are in library.", + modifier = Modifier.fillMaxSize(), + ) + return@HorizontalPager + } + + FastScrollLazyColumn( + state = currentScrollState, + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Top, + ) { + mangaListItem( + items = currentTabState.items, + onClick = { item -> + navigator.push( + GlobalSearchScreen(searchQuery = item.title ?: ""), + ) + }, + ) + if (currentTabState.isLoading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + } + } + } + } + } + + if (state.trackerSelectDialog) { + TrackerSelectDialog( + screenModel.trackers, + onDismissRequest = screenModel::toggleTrackerSelectDialog, + onTrackerSelect = screenModel::changeTracker, + currentTrackerId = state.trackerId!!, + ) + } + } +} + +@Composable +fun TrackerMangaListAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + navigateUp: () -> Unit, + onShowTrackerDialogClick: () -> Unit, +) { + AppBar( + navigateUp = navigateUp, + titleContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + maxLines = 1, + ) + } + }, + actions = { + IconButton(onClick = onShowTrackerDialogClick) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = "Select tracker", + ) + } + }, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +fun TrackerSelectDialog( + trackers: List, + onDismissRequest: () -> Unit, + onTrackerSelect: (Long) -> Unit, + currentTrackerId: Long, +) { + AlertDialog( + modifier = Modifier.fillMaxWidth(), + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(stringResource(SYMR.strings.select_tracker)) + }, + text = { + FlowRow( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + trackers.forEach { tracker -> + Box { + TrackLogoIcon( + tracker, + onClick = { + if (tracker.id != currentTrackerId) { + onTrackerSelect(tracker.id) + } + }, + ) + if (tracker.id == currentTrackerId) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier + .align(Alignment.TopEnd) + .size(16.dp), + tint = Color.Green, + ) + } + } + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreenModel.kt b/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreenModel.kt new file mode 100644 index 000000000000..e82fd6f19364 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/tracker/TrackerMangaListScreenModel.kt @@ -0,0 +1,126 @@ +package eu.kanade.presentation.library.tracker + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.data.track.EnhancedTracker +import eu.kanade.tachiyomi.data.track.Tracker +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.domain.manga.interactor.GetLibraryManga +import tachiyomi.domain.track.interactor.GetTracks +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrackerMangaListScreenModel( + private val getLibraryManga: GetLibraryManga = Injekt.get(), + private val getTracks: GetTracks = Injekt.get(), + private val trackerManager: TrackerManager = Injekt.get(), +) : StateScreenModel(TrackerMangaListState()) { + + private var remoteIds: Set = emptySet() + + // TODO: Implement getPaginatedMangaList for all trackers + // When changing, also update in LibraryScreenModel + val trackers: List = trackerManager.loggedInTrackers().filterNot { it is EnhancedTracker }.filter { tracker -> tracker::class in listOf(Anilist::class, MyAnimeList::class) } + private var tracker: Tracker = trackers.first() + + init { + screenModelScope.launchIO { + getLibraryManga.subscribe().collectLatest { mangaList -> + val mangaIds = mangaList.map { it.id }.toSet() + remoteIds = getTracks.await().filter { it.mangaId in mangaIds && it.trackerId == tracker.id }.map { it.remoteId }.toSet() + mutableState.update { + TrackerMangaListState( + trackerId = tracker.id, + statusList = tracker.getStatusList(), + getStatusRes = tracker::getStatus, + ) + } + } + } + } + + fun changeTab(index: Int) { + mutableState.update { + it.copy(currentTabIndex = index) + } + if (mutableState.value.tabs[index]?.items?.isEmpty() != false) { + loadNextPage(index) + } + } + + fun loadNextPage(tabIndex: Int) { + val currentTab = mutableState.value.tabs[tabIndex] ?: TabMangaList() + if (currentTab.isLoading || currentTab.endReached) return + + screenModelScope.launchIO { + val statusId = mutableState.value.statusList.getOrNull(tabIndex) ?: return@launchIO + val currentPage = currentTab.page + + mutableState.update { + it.copy( + tabs = it.tabs + (tabIndex to currentTab.copy(isLoading = true)), + ) + } + + val newItems = tracker.getPaginatedMangaList(currentPage, statusId).filterNot { it.remoteId in remoteIds } + + mutableState.update { + val updatedTab = currentTab.copy( + items = currentTab.items + newItems, + page = currentPage + 1, + isLoading = false, + endReached = newItems.isEmpty(), + ) + it.copy(tabs = it.tabs + (tabIndex to updatedTab)) + } + } + } + + fun getTrackerName(): String { + return tracker.name + } + + fun changeTracker(trackerId: Long) { + tracker = trackerManager.get(trackerId)!! + mutableState.update { + TrackerMangaListState( + trackerId = trackerId, + statusList = tracker.getStatusList(), + getStatusRes = tracker::getStatus, + trackerSelectDialog = false, + ) + } + } + + fun toggleTrackerSelectDialog() { + mutableState.update { + it.copy(trackerSelectDialog = !it.trackerSelectDialog) + } + } +} + +@Immutable +data class TrackerMangaListState( + val statusList: List = emptyList(), + val getStatusRes: (Long) -> StringResource? = { null }, + val trackerId: Long? = null, + val currentTabIndex: Int = 0, + val tabs: Map = emptyMap(), + val trackerSelectDialog: Boolean = false, +) + +@Immutable +data class TabMangaList( + val items: List = emptyList(), + val page: Int = 1, + val endReached: Boolean = false, + val isLoading: Boolean = false, +) diff --git a/app/src/main/java/eu/kanade/presentation/library/tracker/components/MangaListItem.kt b/app/src/main/java/eu/kanade/presentation/library/tracker/components/MangaListItem.kt new file mode 100644 index 000000000000..699ce65a7d3d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/tracker/components/MangaListItem.kt @@ -0,0 +1,70 @@ +package eu.kanade.presentation.library.tracker.components + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.manga.components.MangaCover +import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata + +fun LazyListScope.mangaListItem( + items: List, + onClick: (TrackMangaMetadata) -> Unit, +) { + items( + items = items, + key = { it.remoteId!! }, + ) { item -> + Box(modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null, placementSpec = tween(300))) { + MangaListItem( + modifier = Modifier, + manga = item, + onClick = { onClick(item) }, + ) + } + } +} + +@Composable +fun MangaListItem( + modifier: Modifier, + manga: TrackMangaMetadata, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .height(56.dp) + .combinedClickable( + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Square( + modifier = Modifier + .fillMaxHeight(), + data = manga.thumbnailUrl, + ) + Text( + text = manga.title!!, + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/tracker/components/TrackStatusTabs.kt b/app/src/main/java/eu/kanade/presentation/library/tracker/components/TrackStatusTabs.kt new file mode 100644 index 000000000000..77957be349d0 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/tracker/components/TrackStatusTabs.kt @@ -0,0 +1,53 @@ +package eu.kanade.presentation.library.tracker.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import dev.icerock.moko.resources.StringResource +import tachiyomi.presentation.core.components.material.TabText +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun TrackStatusTabs( + statusList: List, + getStatusRes: (Long) -> StringResource?, + pagerState: PagerState, + onTabItemClick: (Int) -> Unit, +) { + val currentPageIndex = pagerState.currentPage.coerceAtMost(statusList.lastIndex) + + Column( + modifier = Modifier + .zIndex(1f), + ) { + PrimaryScrollableTabRow( + selectedTabIndex = currentPageIndex, + edgePadding = 0.dp, + // TODO: use default when width is fixed upstream + // https://issuetracker.google.com/issues/242879624 + divider = {}, + ) { + statusList.forEachIndexed { index, status -> + Tab( + selected = currentPageIndex == index, + onClick = { onTabItemClick(index) }, + text = { + TabText( + text = stringResource(getStatusRes(status)!!), + ) + }, + unselectedContentColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + HorizontalDivider() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt index f970873b42c4..83105561fca5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt @@ -137,6 +137,10 @@ abstract class BaseTracker( override suspend fun searchById(id: String): TrackSearch? { throw NotImplementedError("Not implemented.") } + + override suspend fun getPaginatedMangaList(page: Int, statusId: Long): List { + throw NotImplementedError("Not implemented.") + } // SY <-- private suspend fun updateRemote(track: Track): Unit = withIOContext { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/Tracker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/Tracker.kt index c935eebe2069..8b9ac992fc40 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/Tracker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/Tracker.kt @@ -92,5 +92,7 @@ interface Tracker { suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? suspend fun searchById(id: String): TrackSearch? + + suspend fun getPaginatedMangaList(page: Int, statusId: Long): List // SY <-- } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 363f2fdb7780..76b7f1a267ee 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -234,14 +234,18 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker { interceptor.setAuth(null) } + // SY --> override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? { return api.getMangaMetadata(track) } - // SY --> override suspend fun searchById(id: String): TrackSearch { return api.searchById(id) } + + override suspend fun getPaginatedMangaList(page: Int, statusId: Long): List { + return api.getPaginatedMangaList(page, statusId, getUsername().toInt()) + } // SY <-- fun saveOAuth(alOAuth: ALOAuth?) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 07aa1b6328c3..0117b8a42fca 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult +import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserMangaListQueryResult import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.POST @@ -435,6 +436,54 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } } + + suspend fun getPaginatedMangaList(page: Int, statusId: Long, userId: Int): List { + return withIOContext { + val query = """ + |query (${'$'}id: Int!, ${'$'}page: Int!, ${'$'}status: MediaListStatus!) { + |Page(perPage: 50, page: ${'$'}page) { + |mediaList(userId: ${'$'}id, type: MANGA, status: ${'$'}status) { + |media { + |id + |title { + |userPreferred + |} + |coverImage { + |large + |} + |} + |} + |} + |} + """.trimMargin() + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("page", page) + put("id", userId) + put("status", statusId.toApiStatus()) + } + } + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .data.page.mediaList + .map { entry -> + TrackMangaMetadata( + remoteId = entry.media.id, + title = entry.media.title.userPreferred, + thumbnailUrl = entry.media.coverImage.large, + ) + } + } + } + } // SY <-- private fun createDate(dateValue: Long): JsonObject { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt index 5e45f8da9001..e3de44f9e5b4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt @@ -5,6 +5,16 @@ import eu.kanade.tachiyomi.data.database.models.Track import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack +fun Long.toApiStatus() = when (this) { + Anilist.READING -> "CURRENT" + Anilist.COMPLETED -> "COMPLETED" + Anilist.ON_HOLD -> "PAUSED" + Anilist.DROPPED -> "DROPPED" + Anilist.PLAN_TO_READ -> "PLANNING" + Anilist.REREADING -> "REPEATING" + else -> throw NotImplementedError("Unknown status: $this") +} + fun Track.toApiStatus() = when (status) { Anilist.READING -> "CURRENT" Anilist.COMPLETED -> "COMPLETED" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserMangaList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserMangaList.kt new file mode 100644 index 000000000000..666e2b610ce7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserMangaList.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALUserMangaListQueryResult( + val data: ALUserMangaListPage, +) + +@Serializable +data class ALUserMangaListPage( + @SerialName("Page") + val page: ALUserMangaListMediaList, +) + +@Serializable +data class ALUserMangaListMediaList( + val mediaList: List, +) + +@Serializable +data class ALUserMediaListEntry( + val media: ALUserMediaListEntryMedia, +) + +@Serializable +data class ALUserMediaListEntryMedia( + val id: Long, + val title: ALItemTitle, + val coverImage: ItemCover, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackMangaMetadata.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackMangaMetadata.kt index 0df41b588431..53ff798adb20 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackMangaMetadata.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackMangaMetadata.kt @@ -7,4 +7,5 @@ data class TrackMangaMetadata( val description: String? = null, val authors: String? = null, val artists: String? = null, + val libraryStatus: Int? = null, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 8d8c9d2b6dff..ba5cb24bf689 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -156,14 +156,18 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker { interceptor.setAuth(null) } + // SY --> override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? { return api.getMangaMetadata(track) } - // SY --> override suspend fun searchById(id: String): TrackSearch { return api.getMangaDetails(id.toInt()) } + + override suspend fun getPaginatedMangaList(page: Int, statusId: Long): List { + return api.getPaginatedMangaList(page, statusId) + } // SY <-- fun getIfAuthExpired(): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 71b7450d4dfa..046161cbf194 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -230,6 +230,35 @@ class MyAnimeListApi( } } + suspend fun getPaginatedMangaList(page: Int, statusId: Long): List { + return withIOContext { + val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() + .appendQueryParameter("status", "${statusId.toMyAnimeListStatus()}") + .appendQueryParameter("fields", "list_status") + .appendQueryParameter("limit", 50.toString()) + .appendQueryParameter("offset", ((page - 1) * 50).toString()) + + val request = Request.Builder().url(urlBuilder.build().toString()).get().build() + with(json) { + val data = authClient.newCall(request) + .awaitSuccess() + .parseAs() + .data + data.mapNotNull { + if (statusId == MyAnimeList.REREADING && !it.listStatus!!.isRereading) { + null + } else { + TrackMangaMetadata( + remoteId = it.node.id.toLong(), + title = it.node.title, + thumbnailUrl = it.node.covers?.large, + ) + } + } + } + } + } + private suspend fun getListPage(offset: Int): MALUserSearchResult { return withIOContext { val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt index 593111a7d7e3..cc39c201121e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt @@ -2,6 +2,16 @@ package eu.kanade.tachiyomi.data.track.myanimelist import eu.kanade.tachiyomi.data.database.models.Track +fun Long.toMyAnimeListStatus() = when (this) { + MyAnimeList.READING -> "reading" + MyAnimeList.COMPLETED -> "completed" + MyAnimeList.ON_HOLD -> "on_hold" + MyAnimeList.DROPPED -> "dropped" + MyAnimeList.PLAN_TO_READ -> "plan_to_read" + MyAnimeList.REREADING -> "reading" + else -> null +} + fun Track.toMyAnimeListStatus() = when (status) { MyAnimeList.READING -> "reading" MyAnimeList.COMPLETED -> "completed" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt index fad099a24b03..6adf22913890 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.track.myanimelist.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -11,6 +12,8 @@ data class MALUserSearchResult( @Serializable data class MALUserSearchItem( val node: MALUserSearchItemNode, + @SerialName("list_status") + val listStatus: MALListItemStatus?, ) @Serializable @@ -22,4 +25,6 @@ data class MALUserSearchPaging( data class MALUserSearchItemNode( val id: Int, val title: String, + @SerialName("main_picture") + val covers: MALMangaCovers?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index fb3d7cc7f0bf..580ba4d230c0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -29,8 +29,11 @@ import eu.kanade.presentation.manga.DownloadAction import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.track.EnhancedTracker import eu.kanade.tachiyomi.data.track.TrackStatus import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource @@ -288,6 +291,20 @@ class LibraryScreenModel( mutableState.update { it.copy(isSyncEnabled = syncService != 0) } } .launchIn(screenModelScope) + + screenModelScope.launchIO { + trackerManager.loggedInTrackersFlow().collectLatest { trackerList -> + mutableState.update { state -> + state.copy( + hasLoggedInTrackers = trackerList.filterNot { it is EnhancedTracker }.any { tracker -> + tracker::class in listOf( + Anilist::class, MyAnimeList::class, + ) + }, + ) + } + } + } // SY <-- } @@ -1399,6 +1416,7 @@ class LibraryScreenModel( val isSyncEnabled: Boolean = false, val ogCategories: List = emptyList(), val groupType: Int = LibraryGroup.BY_DEFAULT, + val hasLoggedInTrackers: Boolean = false, // SY <-- ) { private val libraryCount by lazy { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 044371ec9231..e6099a0a69cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -37,6 +37,7 @@ import eu.kanade.presentation.library.components.LibraryToolbar import eu.kanade.presentation.library.components.SyncFavoritesConfirmDialog import eu.kanade.presentation.library.components.SyncFavoritesProgressDialog import eu.kanade.presentation.library.components.SyncFavoritesWarningDialog +import eu.kanade.presentation.library.tracker.TrackerMangaListScreen import eu.kanade.presentation.manga.components.LibraryBottomActionMenu import eu.kanade.presentation.more.onboarding.GETTING_STARTED_URL import eu.kanade.presentation.util.Tab @@ -177,6 +178,8 @@ data object LibraryTab : Tab { // SY --> onClickSyncExh = screenModel::openFavoritesSyncDialog.takeIf { state.showSyncExh }, isSyncEnabled = state.isSyncEnabled, + onClickTrackerManga = { navigator.push(TrackerMangaListScreen()) }, + hasLoggedInTrackers = state.hasLoggedInTrackers, // SY <-- searchQuery = state.searchQuery, onSearchQueryChange = screenModel::search, diff --git a/app/src/main/java/eu/kanade/test/DummyTracker.kt b/app/src/main/java/eu/kanade/test/DummyTracker.kt index d64b647a6a4d..284df4306deb 100644 --- a/app/src/main/java/eu/kanade/test/DummyTracker.kt +++ b/app/src/main/java/eu/kanade/test/DummyTracker.kt @@ -4,6 +4,7 @@ import android.graphics.Color import dev.icerock.moko.resources.StringResource import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.Tracker +import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -127,10 +128,12 @@ data class DummyTracker( ) = Unit override suspend fun getMangaMetadata( - track: tachiyomi.domain.track.model.Track, - ): eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata = eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata( + track: Track, + ): TrackMangaMetadata = TrackMangaMetadata( 0, "test", "test", "test", "test", "test", ) override suspend fun searchById(id: String) = null + + override suspend fun getPaginatedMangaList(page: Int, statusId: Long): List = emptyList() }