diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5ebf2420..34857a998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,6 @@ plugins { if (gradle.startParameter.taskRequests.toString().contains("Standard")) { apply() - apply() } shortcutHelper.setFilePath("./shortcuts.xml") @@ -29,7 +28,7 @@ android { minSdk = AndroidConfig.minSdk targetSdk = AndroidConfig.targetSdk versionCode = 90 - versionName = "0.13.5.0" + versionName = "0.14.0.0" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"") @@ -296,7 +295,6 @@ dependencies { // Crash reports/analytics implementation(libs.acra.http) implementation(libs.firebase.analytics) - implementation(libs.firebase.crashlytics) // Shizuku implementation(libs.bundles.shizuku) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2c0f3b5f..aab1f6f21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -141,7 +141,7 @@ android:exported="false" /> @@ -225,12 +225,6 @@ android:name=".data.notification.NotificationReceiver" android:exported="false" /> - - - - - - - ?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long) -> Anime = - { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewer, episodeFlags, coverLastModified, dateAdded -> +val animeMapper: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy) -> Anime = + { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy -> Anime( id = id, source = source, favorite = favorite, lastUpdate = lastUpdate ?: 0, dateAdded = dateAdded, - viewerFlags = viewer, - episodeFlags = episodeFlags, - coverLastModified = coverLastModified, - url = url, - title = title, - artist = artist, - author = author, - description = description, - genre = genre, - status = status, - thumbnailUrl = thumbnailUrl, - initialized = initialized, - ) - } - -val animeEpisodeMapper: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, Long, Long, String, String, String?, Boolean, Boolean, Long, Long, Float, Long, Long, Long) -> Pair = - { _id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, next_update, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, episodeId, animeId, chapterUrl, name, scanlator, seen, bookmark, lastSecondSeen, totalSeconds, episodeNumber, sourceOrder, dateFetch, dateUpload -> - Anime( - id = _id, - source = source, - favorite = favorite, - lastUpdate = lastUpdate ?: 0, - dateAdded = dateAdded, viewerFlags = viewerFlags, episodeFlags = episodeFlags, coverLastModified = coverLastModified, @@ -46,46 +23,40 @@ val animeEpisodeMapper: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, Long, Long, Long) -> AnimelibAnime = - { _id, source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, episode_flags, cover_last_modified, date_added, unseen_count, seen_count, category -> - AnimelibAnime().apply { - this.id = _id - this.source = source - this.url = url - this.artist = artist - this.author = author - this.description = description - this.genre = genre?.joinToString() - this.title = title - this.status = status.toInt() - this.thumbnail_url = thumbnail_url - this.favorite = favorite - this.last_update = last_update ?: 0 - this.initialized = initialized - this.viewer_flags = viewer.toInt() - this.episode_flags = episode_flags.toInt() - this.cover_last_modified = cover_last_modified - this.date_added = date_added - this.unseenCount = unseen_count.toInt() - this.seenCount = seen_count.toInt() - this.category = category.toInt() - } +val animelibAnime: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long) -> AnimelibAnime = + { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy, totalCount, seenCount, latestUpload, episodeFetchedAt, lastSeen, bookmarkCount, category -> + AnimelibAnime( + anime = animeMapper( + id, + source, + url, + artist, + author, + description, + genre, + title, + status, + thumbnailUrl, + favorite, + lastUpdate, + nextUpdate, + initialized, + viewerFlags, + episodeFlags, + coverLastModified, + dateAdded, + updateStrategy, + ), + category = category, + totalEpisodes = totalCount, + seenCount = seenCount, + bookmarkCount = bookmarkCount, + latestUpload = latestUpload, + episodeFetchedAt = episodeFetchedAt, + lastSeen = lastSeen, + ) } diff --git a/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt index 6ab730d2b..d5b8f35b0 100644 --- a/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt @@ -2,12 +2,13 @@ package eu.kanade.data.anime import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.data.listOfStringsAdapter -import eu.kanade.data.toLong +import eu.kanade.data.updateStrategyAdapter import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.model.AnimeUpdate import eu.kanade.domain.anime.repository.AnimeRepository -import eu.kanade.tachiyomi.data.database.models.AnimelibAnime +import eu.kanade.domain.animelib.model.AnimelibAnime import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toLong import kotlinx.coroutines.flow.Flow import logcat.LogPriority @@ -24,7 +25,11 @@ class AnimeRepositoryImpl( } override suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime? { - return handler.awaitOneOrNull { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) } + return handler.awaitOneOrNull(inTransaction = true) { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) } + } + + override fun getAnimeByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow { + return handler.subscribeToOneOrNull { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) } } override suspend fun getFavorites(): List { @@ -32,11 +37,11 @@ class AnimeRepositoryImpl( } override suspend fun getAnimelibAnime(): List { - return handler.awaitList { animesQueries.getAnimelib(animelibAnime) } + return handler.awaitList { animelibViewQueries.animelib(animelibAnime) } } override fun getAnimelibAnimeAsFlow(): Flow> { - return handler.subscribeToList { animesQueries.getAnimelib(animelibAnime) } + return handler.subscribeToList { animelibViewQueries.animelib(animelibAnime) } } override fun getFavoritesBySourceId(sourceId: Long): Flow> { @@ -69,7 +74,7 @@ class AnimeRepositoryImpl( } override suspend fun insert(anime: Anime): Long? { - return handler.awaitOneOrNull { + return handler.awaitOneOrNull(inTransaction = true) { animesQueries.insert( source = anime.source, url = anime.url, @@ -88,6 +93,7 @@ class AnimeRepositoryImpl( episodeFlags = anime.episodeFlags, coverLastModified = anime.coverLastModified, dateAdded = anime.dateAdded, + updateStrategy = anime.updateStrategy, ) animesQueries.selectLastInsertedRowId() } @@ -103,9 +109,9 @@ class AnimeRepositoryImpl( } } - override suspend fun updateAll(values: List): Boolean { + override suspend fun updateAll(animeUpdates: List): Boolean { return try { - partialUpdate(*values.toTypedArray()) + partialUpdate(*animeUpdates.toTypedArray()) true } catch (e: Exception) { logcat(LogPriority.ERROR, e) @@ -113,9 +119,9 @@ class AnimeRepositoryImpl( } } - private suspend fun partialUpdate(vararg values: AnimeUpdate) { + private suspend fun partialUpdate(vararg animeUpdates: AnimeUpdate) { handler.await(inTransaction = true) { - values.forEach { value -> + animeUpdates.forEach { value -> animesQueries.update( source = value.source, url = value.url, @@ -134,6 +140,7 @@ class AnimeRepositoryImpl( coverLastModified = value.coverLastModified, dateAdded = value.dateAdded, animeId = value.id, + updateStrategy = value.updateStrategy?.let(updateStrategyAdapter::encode), ) } } diff --git a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt index e744eff4a..b9d1b8c80 100644 --- a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt @@ -1,28 +1,21 @@ package eu.kanade.data.animehistory -import androidx.paging.PagingSource import eu.kanade.data.AnimeDatabaseHandler -import eu.kanade.data.anime.animeMapper -import eu.kanade.data.episode.episodeMapper -import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository -import eu.kanade.domain.episode.model.Episode import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.coroutines.flow.Flow import logcat.LogPriority class AnimeHistoryRepositoryImpl( private val handler: AnimeDatabaseHandler, ) : AnimeHistoryRepository { - override fun getHistory(query: String): PagingSource { - return handler.subscribeToPagingSource( - countQuery = { animehistoryViewQueries.countHistory(query) }, - queryProvider = { limit, offset -> - animehistoryViewQueries.animehistory(query, limit, offset, animehistoryWithRelationsMapper) - }, - ) + override fun getHistory(query: String): Flow> { + return handler.subscribeToList { + animehistoryViewQueries.animehistory(query, animehistoryWithRelationsMapper) + } } override suspend fun getLastHistory(): AnimeHistoryWithRelations? { @@ -31,45 +24,6 @@ class AnimeHistoryRepositoryImpl( } } - override suspend fun getNextEpisode(animeId: Long, episodeId: Long): Episode? { - val episode = handler.awaitOne { episodesQueries.getEpisodeById(episodeId, episodeMapper) } - val anime = handler.awaitOne { animesQueries.getAnimeById(animeId, animeMapper) } - - if (!episode.seen) { - return episode - } - - val sortFunction: (Episode, Episode) -> Int = when (anime.sorting) { - Anime.EPISODE_SORTING_SOURCE -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) } - Anime.EPISODE_SORTING_NUMBER -> { c1, c2 -> c1.episodeNumber.compareTo(c2.episodeNumber) } - Anime.EPISODE_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) } - else -> throw NotImplementedError("Unknown sorting method") - } - - val episodes = handler.awaitList { episodesQueries.getEpisodesByAnimeId(animeId, episodeMapper) } - .sortedWith(sortFunction) - - val currEpisodeIndex = episodes.indexOfFirst { episode.id == it.id } - return when (anime.sorting) { - Anime.EPISODE_SORTING_SOURCE -> episodes.getOrNull(currEpisodeIndex + 1) - Anime.EPISODE_SORTING_NUMBER -> { - val episodeNumber = episode.episodeNumber - - ((currEpisodeIndex + 1) until episodes.size) - .map { episodes[it] } - .firstOrNull { - it.episodeNumber > episodeNumber && - it.episodeNumber <= episodeNumber + 1 - } - } - Anime.EPISODE_SORTING_UPLOAD_DATE -> { - episodes.drop(currEpisodeIndex + 1) - .firstOrNull { it.dateUpload >= episode.dateUpload } - } - else -> throw NotImplementedError("Unknown sorting method") - } - } - override suspend fun resetHistory(historyId: Long) { try { handler.await { animehistoryQueries.resetAnimeHistoryById(historyId) } diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceDataRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceDataRepositoryImpl.kt new file mode 100644 index 000000000..40a5a8ded --- /dev/null +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceDataRepositoryImpl.kt @@ -0,0 +1,23 @@ +package eu.kanade.data.animesource + +import eu.kanade.data.AnimeDatabaseHandler +import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.animesource.repository.AnimeSourceDataRepository +import kotlinx.coroutines.flow.Flow + +class AnimeSourceDataRepositoryImpl( + private val handler: AnimeDatabaseHandler, +) : AnimeSourceDataRepository { + + override fun subscribeAll(): Flow> { + return handler.subscribeToList { animesourcesQueries.findAll(animesourceDataMapper) } + } + + override suspend fun getSourceData(id: Long): AnimeSourceData? { + return handler.awaitOneOrNull { animesourcesQueries.findOne(id, animesourceDataMapper) } + } + + override suspend fun upsertSourceData(id: Long, lang: String, name: String) { + handler.await { animesourcesQueries.upsert(id, lang, name) } + } +} diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt index 591c8b4c6..dcbdcf744 100644 --- a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt @@ -11,7 +11,7 @@ val animesourceMapper: (eu.kanade.tachiyomi.animesource.AnimeSource) -> AnimeSou source.lang, source.name, supportsLatest = false, - isStub = source is AnimeSourceManager.StubSource, + isStub = source is AnimeSourceManager.StubAnimeSource, ) } diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourcePagingSource.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourcePagingSource.kt new file mode 100644 index 000000000..261171b19 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourcePagingSource.kt @@ -0,0 +1,63 @@ +package eu.kanade.data.animesource + +import androidx.paging.PagingState +import eu.kanade.data.episode.NoEpisodesException +import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType +import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.util.lang.awaitSingle +import eu.kanade.tachiyomi.util.lang.withIOContext + +abstract class AnimeSourcePagingSource( + protected val source: AnimeCatalogueSource, +) : AnimeSourcePagingSourceType() { + + abstract suspend fun requestNextPage(currentPage: Int): AnimesPage + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 + + val animesPage = try { + withIOContext { + requestNextPage(page.toInt()) + .takeIf { it.animes.isNotEmpty() } + ?: throw NoEpisodesException() + } + } catch (e: Exception) { + return LoadResult.Error(e) + } + + return LoadResult.Page( + data = animesPage.animes, + prevKey = null, + nextKey = if (animesPage.hasNextPage) page + 1 else null, + ) + } + + override fun getRefreshKey(state: PagingState): Long? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } +} + +class AnimeSourceSearchPagingSource(source: AnimeCatalogueSource, val query: String, val filters: AnimeFilterList) : AnimeSourcePagingSource(source) { + override suspend fun requestNextPage(currentPage: Int): AnimesPage { + return source.fetchSearchAnime(currentPage, query, filters).awaitSingle() + } +} + +class AnimeSourcePopularPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) { + override suspend fun requestNextPage(currentPage: Int): AnimesPage { + return source.fetchPopularAnime(currentPage).awaitSingle() + } +} + +class AnimeSourceLatestPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) { + override suspend fun requestNextPage(currentPage: Int): AnimesPage { + return source.fetchLatestUpdates(currentPage).awaitSingle() + } +} diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt index a57fb6353..9b1e613a3 100644 --- a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt @@ -2,13 +2,15 @@ package eu.kanade.data.animesource import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType +import eu.kanade.domain.animesource.model.AnimeSourceWithCount import eu.kanade.domain.animesource.repository.AnimeSourceRepository +import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.animesource.LocalAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource class AnimeSourceRepositoryImpl( private val sourceManager: AnimeSourceManager, @@ -41,21 +43,32 @@ class AnimeSourceRepositoryImpl( } } - override fun getSourcesWithNonLibraryAnime(): Flow>> { + override fun getSourcesWithNonLibraryAnime(): Flow> { val sourceIdWithNonLibraryAnime = handler.subscribeToList { animesQueries.getSourceIdsWithNonLibraryAnime() } return sourceIdWithNonLibraryAnime.map { sourceId -> sourceId.map { (sourceId, count) -> val source = sourceManager.getOrStub(sourceId) - source to count + AnimeSourceWithCount(animesourceMapper(source), count) } } } - override suspend fun getAnimeSourceData(id: Long): AnimeSourceData? { - return handler.awaitOneOrNull { animesourcesQueries.getAnimeSourceData(id, animesourceDataMapper) } + override fun search( + sourceId: Long, + query: String, + filterList: AnimeFilterList, + ): AnimeSourcePagingSourceType { + val source = sourceManager.get(sourceId) as AnimeCatalogueSource + return AnimeSourceSearchPagingSource(source, query, filterList) } - override suspend fun upsertAnimeSourceData(id: Long, lang: String, name: String) { - handler.await { animesourcesQueries.upsert(id, lang, name) } + override fun getPopular(sourceId: Long): AnimeSourcePagingSourceType { + val source = sourceManager.get(sourceId) as AnimeCatalogueSource + return AnimeSourcePopularPagingSource(source) + } + + override fun getLatest(sourceId: Long): AnimeSourcePagingSourceType { + val source = sourceManager.get(sourceId) as AnimeCatalogueSource + return AnimeSourceLatestPagingSource(source) } } diff --git a/app/src/main/java/eu/kanade/data/animetrack/AnimeTrackRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animetrack/AnimeTrackRepositoryImpl.kt index acce035dc..903696ab9 100644 --- a/app/src/main/java/eu/kanade/data/animetrack/AnimeTrackRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/animetrack/AnimeTrackRepositoryImpl.kt @@ -44,9 +44,9 @@ class AnimeTrackRepositoryImpl( insertValues(*tracks.toTypedArray()) } - private suspend fun insertValues(vararg values: AnimeTrack) { + private suspend fun insertValues(vararg tracks: AnimeTrack) { handler.await(inTransaction = true) { - values.forEach { animeTrack -> + tracks.forEach { animeTrack -> anime_syncQueries.insert( animeId = animeTrack.animeId, syncId = animeTrack.syncId, diff --git a/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesMapper.kt b/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesMapper.kt new file mode 100644 index 000000000..bc568915b --- /dev/null +++ b/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesMapper.kt @@ -0,0 +1,26 @@ +package eu.kanade.data.animeupdates + +import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations +import eu.kanade.domain.manga.model.MangaCover + +val updateWithRelationMapper: (Long, String, Long, String, String?, Boolean, Boolean, Long, Boolean, String?, Long, Long, Long) -> AnimeUpdatesWithRelations = { + animeId, animeTitle, episodeId, episodeName, scanlator, seen, bookmark, sourceId, favorite, thumbnailUrl, coverLastModified, _, dateFetch -> + AnimeUpdatesWithRelations( + animeId = animeId, + animeTitle = animeTitle, + episodeId = episodeId, + episodeName = episodeName, + scanlator = scanlator, + seen = seen, + bookmark = bookmark, + sourceId = sourceId, + dateFetch = dateFetch, + coverData = MangaCover( + mangaId = animeId, + sourceId = sourceId, + isMangaFavorite = favorite, + url = thumbnailUrl, + lastModified = coverLastModified, + ), + ) +} diff --git a/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesRepositoryImpl.kt new file mode 100644 index 000000000..318546007 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/animeupdates/AnimeUpdatesRepositoryImpl.kt @@ -0,0 +1,17 @@ +package eu.kanade.data.animeupdates + +import eu.kanade.data.AnimeDatabaseHandler +import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations +import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository +import kotlinx.coroutines.flow.Flow + +class AnimeUpdatesRepositoryImpl( + val databaseHandler: AnimeDatabaseHandler, +) : AnimeUpdatesRepository { + + override fun subscribeAll(after: Long): Flow> { + return databaseHandler.subscribeToList { + animeupdatesViewQueries.animeupdates(after, updateWithRelationMapper) + } + } +} diff --git a/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImplAnime.kt b/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImplAnime.kt index a5b2a5d43..4177d57a9 100644 --- a/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImplAnime.kt +++ b/app/src/main/java/eu/kanade/data/category/CategoryRepositoryImplAnime.kt @@ -4,13 +4,17 @@ import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.CategoryUpdate import eu.kanade.domain.category.repository.CategoryRepositoryAnime -import eu.kanade.domain.category.repository.DuplicateNameException +import eu.kanade.tachiyomi.mi.AnimeDatabase import kotlinx.coroutines.flow.Flow class CategoryRepositoryImplAnime( private val handler: AnimeDatabaseHandler, ) : CategoryRepositoryAnime { + override suspend fun get(id: Long): Category? { + return handler.awaitOneOrNull { categoriesQueries.getCategory(id, categoryMapper) } + } + override suspend fun getAll(): List { return handler.awaitList { categoriesQueries.getCategories(categoryMapper) } } @@ -31,28 +35,42 @@ class CategoryRepositoryImplAnime( } } - @Throws(DuplicateNameException::class) - override suspend fun insert(name: String, order: Long) { - if (checkDuplicateName(name)) throw DuplicateNameException(name) + override suspend fun insert(category: Category) { handler.await { categoriesQueries.insert( - name = name, - order = order, - flags = 0L, + name = category.name, + order = category.order, + flags = category.flags, ) } } - @Throws(DuplicateNameException::class) - override suspend fun update(payload: CategoryUpdate) { - if (payload.name != null && checkDuplicateName(payload.name)) throw DuplicateNameException(payload.name) + override suspend fun updatePartial(update: CategoryUpdate) { handler.await { - categoriesQueries.update( - name = payload.name, - order = payload.order, - flags = payload.flags, - categoryId = payload.id, - ) + updatePartialBlocking(update) + } + } + + override suspend fun updatePartial(updates: List) { + handler.await(inTransaction = true) { + for (update in updates) { + updatePartialBlocking(update) + } + } + } + + private fun AnimeDatabase.updatePartialBlocking(update: CategoryUpdate) { + categoriesQueries.update( + name = update.name, + order = update.order, + flags = update.flags, + categoryId = update.id, + ) + } + + override suspend fun updateAllFlags(flags: Long?) { + handler.await { + categoriesQueries.updateAllFlags(flags) } } @@ -63,10 +81,4 @@ class CategoryRepositoryImplAnime( ) } } - - override suspend fun checkDuplicateName(name: String): Boolean { - return handler - .awaitList { categoriesQueries.getCategories() } - .any { it.name == name } - } } diff --git a/app/src/main/java/eu/kanade/data/chapter/episodeMapper.kt b/app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt similarity index 100% rename from app/src/main/java/eu/kanade/data/chapter/episodeMapper.kt rename to app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt diff --git a/app/src/main/java/eu/kanade/data/episode/CleanupEpisodeName.kt b/app/src/main/java/eu/kanade/data/episode/CleanupEpisodeName.kt new file mode 100644 index 000000000..1f3043df4 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/episode/CleanupEpisodeName.kt @@ -0,0 +1,47 @@ +package eu.kanade.data.episode + +object CleanupEpisodeName { + + fun await(episodeName: String, animeTitle: String): String { + return episodeName + .trim() + .removePrefix(animeTitle) + .trim(*EPISODE_TRIM_CHARS) + } + + private val EPISODE_TRIM_CHARS = arrayOf( + // Whitespace + ' ', + '\u0009', + '\u000A', + '\u000B', + '\u000C', + '\u000D', + '\u0020', + '\u0085', + '\u00A0', + '\u1680', + '\u2000', + '\u2001', + '\u2002', + '\u2003', + '\u2004', + '\u2005', + '\u2006', + '\u2007', + '\u2008', + '\u2009', + '\u200A', + '\u2028', + '\u2029', + '\u202F', + '\u205F', + '\u3000', + + // Separators + '-', + '_', + ',', + ':', + ).toCharArray() +} diff --git a/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt index 59cd0d8bd..89c30af14 100644 --- a/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt @@ -1,11 +1,11 @@ package eu.kanade.data.episode import eu.kanade.data.AnimeDatabaseHandler -import eu.kanade.data.toLong import eu.kanade.domain.episode.model.Episode import eu.kanade.domain.episode.model.EpisodeUpdate import eu.kanade.domain.episode.repository.EpisodeRepository import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toLong import kotlinx.coroutines.flow.Flow import logcat.LogPriority @@ -83,6 +83,10 @@ class EpisodeRepositoryImpl( return handler.awaitList { episodesQueries.getEpisodesByAnimeId(animeId, episodeMapper) } } + override suspend fun getBookmarkedEpisodesByAnimeId(animeId: Long): List { + return handler.awaitList { episodesQueries.getBookmarkedEpisodesByAnimeId(animeId, episodeMapper) } + } + override suspend fun getEpisodeById(id: Long): Episode? { return handler.awaitOneOrNull { episodesQueries.getEpisodeById(id, episodeMapper) } } diff --git a/app/src/main/java/eu/kanade/data/source/NoResultsException.kt b/app/src/main/java/eu/kanade/data/source/NoResultsException.kt deleted file mode 100644 index e0eee5fc6..000000000 --- a/app/src/main/java/eu/kanade/data/source/NoResultsException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eu.kanade.data.source - -class NoResultsException : Exception() diff --git a/app/src/main/java/eu/kanade/data/source/SourcePagingSource.kt b/app/src/main/java/eu/kanade/data/source/SourcePagingSource.kt index 87c6e1c51..728f0c6db 100644 --- a/app/src/main/java/eu/kanade/data/source/SourcePagingSource.kt +++ b/app/src/main/java/eu/kanade/data/source/SourcePagingSource.kt @@ -1,6 +1,7 @@ package eu.kanade.data.source import androidx.paging.PagingState +import eu.kanade.data.chapter.NoChaptersException import eu.kanade.domain.source.model.SourcePagingSourceType import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList @@ -22,7 +23,7 @@ abstract class SourcePagingSource( withIOContext { requestNextPage(page.toInt()) .takeIf { it.mangas.isNotEmpty() } - ?: throw NoResultsException() + ?: throw NoChaptersException() } } catch (e: Exception) { return LoadResult.Error(e) diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 1891e7d92..02d64a849 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -2,8 +2,10 @@ package eu.kanade.domain import eu.kanade.data.anime.AnimeRepositoryImpl import eu.kanade.data.animehistory.AnimeHistoryRepositoryImpl +import eu.kanade.data.animesource.AnimeSourceDataRepositoryImpl import eu.kanade.data.animesource.AnimeSourceRepositoryImpl import eu.kanade.data.animetrack.AnimeTrackRepositoryImpl +import eu.kanade.data.animeupdates.AnimeUpdatesRepositoryImpl import eu.kanade.data.category.CategoryRepositoryImpl import eu.kanade.data.category.CategoryRepositoryImplAnime import eu.kanade.data.chapter.ChapterRepositoryImpl @@ -13,10 +15,12 @@ import eu.kanade.data.manga.MangaRepositoryImpl import eu.kanade.data.source.SourceDataRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.data.track.TrackRepositoryImpl +import eu.kanade.data.updates.UpdatesRepositoryImpl import eu.kanade.domain.anime.interactor.GetAnime import eu.kanade.domain.anime.interactor.GetAnimeWithEpisodes import eu.kanade.domain.anime.interactor.GetAnimelibAnime import eu.kanade.domain.anime.interactor.GetDuplicateLibraryAnime +import eu.kanade.domain.anime.interactor.NetworkToLocalAnime import eu.kanade.domain.anime.interactor.SetAnimeEpisodeFlags import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.anime.interactor.UpdateAnime @@ -24,47 +28,50 @@ import eu.kanade.domain.anime.repository.AnimeRepository import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources -import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionUpdates -import eu.kanade.domain.animeextension.interactor.GetAnimeExtensions -import eu.kanade.domain.animehistory.interactor.DeleteAnimeHistoryTable +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionsByType +import eu.kanade.domain.animehistory.interactor.DeleteAllAnimeHistory import eu.kanade.domain.animehistory.interactor.GetAnimeHistory import eu.kanade.domain.animehistory.interactor.GetNextEpisode import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryByAnimeId import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryById import eu.kanade.domain.animehistory.interactor.UpsertAnimeHistory import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository -import eu.kanade.domain.animesource.interactor.GetAnimeSourceData import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithFavoriteCount import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithNonLibraryAnime import eu.kanade.domain.animesource.interactor.GetEnabledAnimeSources import eu.kanade.domain.animesource.interactor.GetLanguagesWithAnimeSources +import eu.kanade.domain.animesource.interactor.GetRemoteAnime import eu.kanade.domain.animesource.interactor.ToggleAnimeSource import eu.kanade.domain.animesource.interactor.ToggleAnimeSourcePin -import eu.kanade.domain.animesource.interactor.UpsertAnimeSourceData +import eu.kanade.domain.animesource.repository.AnimeSourceDataRepository import eu.kanade.domain.animesource.repository.AnimeSourceRepository import eu.kanade.domain.animetrack.interactor.DeleteAnimeTrack import eu.kanade.domain.animetrack.interactor.GetAnimeTracks +import eu.kanade.domain.animetrack.interactor.GetTracksPerAnime import eu.kanade.domain.animetrack.interactor.InsertAnimeTrack import eu.kanade.domain.animetrack.repository.AnimeTrackRepository -import eu.kanade.data.updates.UpdatesRepositoryImpl +import eu.kanade.domain.animeupdates.interactor.GetAnimeUpdates +import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository +import eu.kanade.domain.category.interactor.CreateAnimeCategoryWithName import eu.kanade.domain.category.interactor.CreateCategoryWithName +import eu.kanade.domain.category.interactor.DeleteAnimeCategory import eu.kanade.domain.category.interactor.DeleteCategory -import eu.kanade.domain.category.interactor.DeleteCategoryAnime +import eu.kanade.domain.category.interactor.GetAnimeCategories import eu.kanade.domain.category.interactor.GetCategories -import eu.kanade.domain.category.interactor.GetCategoriesAnime -import eu.kanade.domain.category.interactor.SetAnimeCategories -import eu.kanade.domain.category.interactor.RenameCategory import eu.kanade.domain.category.interactor.RenameAnimeCategory -import eu.kanade.domain.category.interactor.ReorderCategory +import eu.kanade.domain.category.interactor.RenameCategory import eu.kanade.domain.category.interactor.ReorderAnimeCategory -import eu.kanade.domain.category.interactor.ResetCategoryFlags +import eu.kanade.domain.category.interactor.ReorderCategory import eu.kanade.domain.category.interactor.ResetAnimeCategoryFlags -import eu.kanade.domain.category.interactor.SetDisplayModeForCategory +import eu.kanade.domain.category.interactor.ResetCategoryFlags +import eu.kanade.domain.category.interactor.SetAnimeCategories import eu.kanade.domain.category.interactor.SetDisplayModeForAnimeCategory +import eu.kanade.domain.category.interactor.SetDisplayModeForCategory import eu.kanade.domain.category.interactor.SetMangaCategories +import eu.kanade.domain.category.interactor.SetSortModeForAnimeCategory import eu.kanade.domain.category.interactor.SetSortModeForCategory +import eu.kanade.domain.category.interactor.UpdateAnimeCategory import eu.kanade.domain.category.interactor.UpdateCategory -import eu.kanade.domain.category.interactor.UpdateCategoryAnime import eu.kanade.domain.category.repository.CategoryRepository import eu.kanade.domain.category.repository.CategoryRepositoryAnime import eu.kanade.domain.chapter.interactor.GetChapter @@ -79,6 +86,7 @@ import eu.kanade.domain.chapter.repository.ChapterRepository import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.episode.interactor.GetEpisode import eu.kanade.domain.episode.interactor.GetEpisodeByAnimeId +import eu.kanade.domain.episode.interactor.SetAnimeDefaultEpisodeFlags import eu.kanade.domain.episode.interactor.SetSeenStatus import eu.kanade.domain.episode.interactor.ShouldUpdateDbEpisode import eu.kanade.domain.episode.interactor.SyncEpisodesWithSource @@ -129,15 +137,14 @@ import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.addFactory import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.get -import eu.kanade.domain.anime.interactor.GetFavorites as GetFavoritesAnime +import eu.kanade.domain.anime.interactor.GetAnimeFavorites as GetFavoritesAnime import eu.kanade.domain.anime.interactor.ResetViewerFlags as ResetViewerFlagsAnime class DomainModule : InjektModule { override fun InjektRegistrar.registerInjectables() { - addSingletonFactory { CategoryRepositoryImplAnime(get()) } - addFactory { GetCategoriesAnime(get()) } + addFactory { GetAnimeCategories(get()) } addFactory { ResetAnimeCategoryFlags(get(), get()) } addFactory { SetDisplayModeForAnimeCategory(get(), get()) } addFactory { SetSortModeForAnimeCategory(get(), get()) } @@ -190,7 +197,7 @@ class DomainModule : InjektModule { addSingletonFactory { AnimeTrackRepositoryImpl(get()) } addFactory { DeleteAnimeTrack(get()) } - addFactory { GetAnimeTracksPerAnime(get()) + addFactory { GetTracksPerAnime(get()) } addFactory { GetAnimeTracks(get()) } addFactory { InsertAnimeTrack(get()) } @@ -200,15 +207,6 @@ class DomainModule : InjektModule { addFactory { GetTracks(get()) } addFactory { InsertTrack(get()) } - addSingletonFactory { ChapterRepositoryImpl(get()) } - addFactory { GetChapter(get()) } - addFactory { GetChapterByMangaId(get()) } - addFactory { UpdateChapter(get()) } - addFactory { SetReadStatus(get(), get(), get(), get()) } - addFactory { ShouldUpdateDbChapter() } - addFactory { SyncChaptersWithSource(get(), get(), get(), get()) } - addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) } - addSingletonFactory { EpisodeRepositoryImpl(get()) } addFactory { GetEpisode(get()) } addFactory { GetEpisodeByAnimeId(get()) } @@ -218,6 +216,15 @@ class DomainModule : InjektModule { addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) } addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) } + addSingletonFactory { ChapterRepositoryImpl(get()) } + addFactory { GetChapter(get()) } + addFactory { GetChapterByMangaId(get()) } + addFactory { UpdateChapter(get()) } + addFactory { SetReadStatus(get(), get(), get(), get()) } + addFactory { ShouldUpdateDbChapter() } + addFactory { SyncChaptersWithSource(get(), get(), get(), get()) } + addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) } + addSingletonFactory { AnimeHistoryRepositoryImpl(get()) } addFactory { DeleteAllAnimeHistory(get()) } addFactory { GetAnimeHistory(get()) } @@ -237,7 +244,7 @@ class DomainModule : InjektModule { addFactory { UpsertHistory(get()) } addFactory { RemoveHistoryById(get()) } addFactory { RemoveHistoryByMangaId(get()) } - + addFactory { DeleteDownload(get(), get()) } addFactory { GetExtensionsByType(get(), get()) } @@ -257,8 +264,6 @@ class DomainModule : InjektModule { addFactory { GetRemoteAnime(get()) } addFactory { GetAnimeSourcesWithFavoriteCount(get(), get()) } addFactory { GetAnimeSourcesWithNonLibraryAnime(get()) } - addFactory { SetAnimeMigrateSorting(get()) } - addFactory { ToggleAnimeLanguage(get()) } addFactory { ToggleAnimeSource(get()) } addFactory { ToggleAnimeSourcePin(get()) } diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnime.kt index a2408abc2..0301e841d 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnime.kt @@ -23,7 +23,7 @@ class GetAnime( return animeRepository.getAnimeByIdAsFlow(id) } - suspend fun await(url: String, sourceId: Long): Anime? { - return animeRepository.getAnimeByUrlAndSourceId(url, sourceId) + fun subscribe(url: String, sourceId: Long): Flow { + return animeRepository.getAnimeByUrlAndSourceIdAsFlow(url, sourceId) } } diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavorites.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeFavorites.kt similarity index 94% rename from app/src/main/java/eu/kanade/domain/anime/interactor/GetFavorites.kt rename to app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeFavorites.kt index baff99260..03579d8d3 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavorites.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeFavorites.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.repository.AnimeRepository import kotlinx.coroutines.flow.Flow -class GetFavorites( +class GetAnimeFavorites( private val animeRepository: AnimeRepository, ) { diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeWithEpisodes.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeWithEpisodes.kt index 74779a0c8..6ff8e633b 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeWithEpisodes.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimeWithEpisodes.kt @@ -24,4 +24,8 @@ class GetAnimeWithEpisodes( suspend fun awaitAnime(id: Long): Anime { return animeRepository.getAnimeById(id) } + + suspend fun awaitEpisodes(id: Long): List { + return episodeRepository.getEpisodeByAnimeId(id) + } } diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimelibAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimelibAnime.kt index e57681d56..a77200ae2 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimelibAnime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/GetAnimelibAnime.kt @@ -1,7 +1,7 @@ package eu.kanade.domain.anime.interactor import eu.kanade.domain.anime.repository.AnimeRepository -import eu.kanade.tachiyomi.data.database.models.AnimelibAnime +import eu.kanade.domain.animelib.model.AnimelibAnime import kotlinx.coroutines.flow.Flow class GetAnimelibAnime( diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/InsertAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/InsertAnime.kt deleted file mode 100644 index d5f30edbe..000000000 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/InsertAnime.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.domain.anime.interactor - -import eu.kanade.domain.anime.model.Anime -import eu.kanade.domain.anime.repository.AnimeRepository - -class InsertAnime( - private val animeRepository: AnimeRepository, -) { - - suspend fun await(anime: Anime): Long? { - return animeRepository.insert(anime) - } -} diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/NetworkToLocalAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/NetworkToLocalAnime.kt new file mode 100644 index 000000000..d39918ce5 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/NetworkToLocalAnime.kt @@ -0,0 +1,35 @@ +package eu.kanade.domain.anime.interactor + +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.anime.repository.AnimeRepository + +class NetworkToLocalAnime( + private val animeRepository: AnimeRepository, +) { + + suspend fun await(anime: Anime, sourceId: Long): Anime { + val localAnime = getAnime(anime.url, sourceId) + return when { + localAnime == null -> { + val id = insertAnime(anime.copy(source = sourceId)) + anime.copy(id = id!!) + } + !localAnime.favorite -> { + // if the anime isn't a favorite, set its display title from source + // if it later becomes a favorite, updated title will go to db + localAnime.copy(title = anime.title) + } + else -> { + localAnime + } + } + } + + private suspend fun getAnime(url: String, sourceId: Long): Anime? { + return animeRepository.getAnimeByUrlAndSourceId(url, sourceId) + } + + private suspend fun insertAnime(anime: Anime): Long? { + return animeRepository.insert(anime) + } +} diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt index 54d6a8714..a2a534ed3 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt @@ -6,8 +6,8 @@ import eu.kanade.domain.anime.model.hasCustomCover import eu.kanade.domain.anime.model.isLocal import eu.kanade.domain.anime.model.toDbAnime import eu.kanade.domain.anime.repository.AnimeRepository +import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.data.cache.AnimeCoverCache -import tachiyomi.animesource.model.AnimeInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date @@ -20,23 +20,30 @@ class UpdateAnime( return animeRepository.update(animeUpdate) } - suspend fun awaitAll(values: List): Boolean { - return animeRepository.updateAll(values) + suspend fun awaitAll(animeUpdates: List): Boolean { + return animeRepository.updateAll(animeUpdates) } suspend fun awaitUpdateFromSource( localAnime: Anime, - remoteAnime: AnimeInfo, + remoteAnime: SAnime, manualFetch: Boolean, coverCache: AnimeCoverCache = Injekt.get(), ): Boolean { - // if the anime isn't a favorite, set its title from source and update in db - val title = if (!localAnime.favorite) remoteAnime.title else null + val remoteTitle = try { + remoteAnime.title + } catch (_: UninitializedPropertyAccessException) { + "" + } - // Never refresh covers if the url is empty to avoid "losing" existing covers - val updateCover = remoteAnime.cover.isNotEmpty() && (manualFetch || localAnime.thumbnailUrl != remoteAnime.cover) - val coverLastModified = if (updateCover) { + // if the anime isn't a favorite, set its title from source and update in db + val title = if (remoteTitle.isEmpty() || localAnime.favorite) null else remoteTitle + + val coverLastModified = when { + // Never refresh covers if the url is empty to avoid "losing" existing covers + remoteAnime.thumbnail_url.isNullOrEmpty() -> null + !manualFetch && localAnime.thumbnailUrl == remoteAnime.thumbnail_url -> null localAnime.isLocal() -> Date().time localAnime.hasCustomCover(coverCache) -> { coverCache.deleteFromCache(localAnime.toDbAnime(), false) @@ -47,19 +54,21 @@ class UpdateAnime( Date().time } } - } else null + + val thumbnailUrl = remoteAnime.thumbnail_url?.takeIf { it.isNotEmpty() } return animeRepository.update( AnimeUpdate( id = localAnime.id, - title = title?.takeIf { it.isNotEmpty() }, + title = title, coverLastModified = coverLastModified, author = remoteAnime.author, artist = remoteAnime.artist, description = remoteAnime.description, - genre = remoteAnime.genres, - thumbnailUrl = remoteAnime.cover.takeIf { it.isNotEmpty() }, + genre = remoteAnime.getGenres(), + thumbnailUrl = thumbnailUrl, status = remoteAnime.status.toLong(), + updateStrategy = remoteAnime.update_strategy, initialized = true, ), ) diff --git a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt index 971b6bbbf..70304d882 100644 --- a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt @@ -1,13 +1,13 @@ package eu.kanade.domain.anime.model import eu.kanade.data.listOfStringsAdapter +import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.data.cache.AnimeCoverCache import eu.kanade.tachiyomi.data.database.models.AnimeImpl -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.widget.ExtendedNavigationView -import tachiyomi.animesource.model.AnimeInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.Serializable @@ -30,6 +30,7 @@ data class Anime( val genre: List?, val status: Long, val thumbnailUrl: String?, + val updateStrategy: UpdateStrategy, val initialized: Boolean, ) : Serializable { @@ -79,7 +80,7 @@ data class Anime( } fun forceDownloaded(): Boolean { - return favorite && Injekt.get().downloadedOnly().get() + return favorite && Injekt.get().downloadedOnly().get() } fun sortDescending(): Boolean { @@ -98,6 +99,28 @@ data class Anime( it.initialized = initialized } + fun copyFrom(other: SAnime): Anime { + val author = other.author ?: author + val artist = other.artist ?: artist + val description = other.description ?: description + val genres = if (other.genre != null) { + other.getGenres() + } else { + genre + } + val thumbnailUrl = other.thumbnail_url ?: thumbnailUrl + return this.copy( + author = author, + artist = artist, + description = description, + genre = genres, + thumbnailUrl = thumbnailUrl, + status = other.status.toLong(), + updateStrategy = other.update_strategy, + initialized = other.initialized && initialized, + ) + } + companion object { // Generic filter that does not filter anything const val SHOW_ALL = 0x00000000L @@ -133,17 +156,18 @@ data class Anime( title = "", source = -1L, favorite = false, - lastUpdate = -1L, - dateAdded = -1L, - viewerFlags = -1L, - episodeFlags = -1L, - coverLastModified = -1L, + lastUpdate = 0L, + dateAdded = 0L, + viewerFlags = 0L, + episodeFlags = 0L, + coverLastModified = 0L, artist = null, author = null, description = null, genre = null, status = 0L, thumbnailUrl = null, + updateStrategy = UpdateStrategy.ALWAYS_UPDATE, initialized = false, ) } @@ -181,20 +205,10 @@ fun Anime.toDbAnime(): DbAnime = AnimeImpl().also { it.genre = genre?.let(listOfStringsAdapter::encode) it.status = status.toInt() it.thumbnail_url = thumbnailUrl + it.update_strategy = updateStrategy it.initialized = initialized } -fun Anime.toAnimeInfo(): AnimeInfo = AnimeInfo( - artist = artist ?: "", - author = author ?: "", - cover = thumbnailUrl ?: "", - description = description ?: "", - genres = genre ?: emptyList(), - key = url, - status = status.toInt(), - title = title, -) - fun Anime.toAnimeUpdate(): AnimeUpdate { return AnimeUpdate( id = id, @@ -213,6 +227,22 @@ fun Anime.toAnimeUpdate(): AnimeUpdate { genre = genre, status = status, thumbnailUrl = thumbnailUrl, + updateStrategy = updateStrategy, + initialized = initialized, + ) +} + +fun SAnime.toDomainAnime(): Anime { + return Anime.create().copy( + url = url, + title = title, + artist = artist, + author = author, + description = description, + genre = getGenres(), + status = status.toLong(), + thumbnailUrl = thumbnail_url, + updateStrategy = update_strategy, initialized = initialized, ) } diff --git a/app/src/main/java/eu/kanade/domain/anime/model/AnimeUpdate.kt b/app/src/main/java/eu/kanade/domain/anime/model/AnimeUpdate.kt index cf0161847..f7cc16c58 100644 --- a/app/src/main/java/eu/kanade/domain/anime/model/AnimeUpdate.kt +++ b/app/src/main/java/eu/kanade/domain/anime/model/AnimeUpdate.kt @@ -1,5 +1,7 @@ package eu.kanade.domain.anime.model +import eu.kanade.tachiyomi.source.model.UpdateStrategy + data class AnimeUpdate( val id: Long, val source: Long? = null, @@ -17,5 +19,6 @@ data class AnimeUpdate( val genre: List? = null, val status: Long? = null, val thumbnailUrl: String? = null, + val updateStrategy: UpdateStrategy? = null, val initialized: Boolean? = null, ) diff --git a/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt b/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt index ef6e43efa..57aabaa08 100644 --- a/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt +++ b/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt @@ -2,7 +2,7 @@ package eu.kanade.domain.anime.repository import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.model.AnimeUpdate -import eu.kanade.tachiyomi.data.database.models.AnimelibAnime +import eu.kanade.domain.animelib.model.AnimelibAnime import kotlinx.coroutines.flow.Flow interface AnimeRepository { @@ -13,6 +13,8 @@ interface AnimeRepository { suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime? + fun getAnimeByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow + suspend fun getFavorites(): List suspend fun getAnimelibAnime(): List @@ -31,5 +33,5 @@ interface AnimeRepository { suspend fun update(update: AnimeUpdate): Boolean - suspend fun updateAll(values: List): Boolean + suspend fun updateAll(animeUpdates: List): Boolean } diff --git a/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteDownload.kt b/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteDownload.kt index 661a8b225..2db0882c5 100644 --- a/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteDownload.kt +++ b/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteDownload.kt @@ -2,19 +2,19 @@ package eu.kanade.domain.animedownload.interactor import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.episode.model.Episode +import eu.kanade.domain.episode.model.toDbEpisode import eu.kanade.tachiyomi.animesource.AnimeSourceManager -import eu.kanade.tachiyomi.data.download.AnimeDownloadManager -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext class DeleteAnimeDownload( private val sourceManager: AnimeSourceManager, private val downloadManager: AnimeDownloadManager, ) { - suspend fun awaitAll(anime: Anime, vararg values: Episode) = withContext(NonCancellable) { + suspend fun awaitAll(anime: Anime, vararg episodes: Episode) = withNonCancellableContext { sourceManager.get(anime.source)?.let { source -> - downloadManager.deleteEpisodes(values.toList(), anime, source) + downloadManager.deleteEpisodes(episodes.map { it.toDbEpisode() }, anime, source) } } } diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt index 2ff2f63aa..dac1a613e 100644 --- a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt @@ -1,20 +1,19 @@ package eu.kanade.domain.animeextension.interactor -import eu.kanade.core.util.asFlow -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager import eu.kanade.tachiyomi.util.system.LocaleHelper import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine class GetAnimeExtensionLanguages( - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, private val extensionManager: AnimeExtensionManager, ) { fun subscribe(): Flow> { return combine( - preferences.enabledLanguages().asFlow(), - extensionManager.getAvailableExtensionsObservable().asFlow(), + preferences.enabledLanguages().changes(), + extensionManager.availableExtensionsFlow, ) { enabledLanguage, availableExtensions -> availableExtensions .map { it.lang } diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt index 9475ef8ee..8df0aa4f3 100644 --- a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt @@ -1,14 +1,14 @@ package eu.kanade.domain.animeextension.interactor +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import eu.kanade.tachiyomi.animesource.AnimeSource -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.model.AnimeExtension import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class GetAnimeExtensionSources( - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { fun subscribe(extension: AnimeExtension.Installed): Flow> { @@ -16,7 +16,7 @@ class GetAnimeExtensionSources( val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1 - return preferences.disabledSources().asFlow().map { disabledSources -> + return preferences.disabledAnimeSources().changes().map { disabledSources -> fun AnimeSource.isEnabled() = id.toString() !in disabledSources extension.sources diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt deleted file mode 100644 index b0d20a8e5..000000000 --- a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt +++ /dev/null @@ -1,25 +0,0 @@ -package eu.kanade.domain.animeextension.interactor - -import eu.kanade.core.util.asFlow -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetAnimeExtensionUpdates( - private val preferences: PreferencesHelper, - private val extensionManager: AnimeExtensionManager, -) { - - fun subscribe(): Flow> { - val showNsfwSources = preferences.showNsfwSource().get() - - return extensionManager.getInstalledExtensionsObservable().asFlow() - .map { installed -> - installed - .filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - } - } -} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt similarity index 50% rename from app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt rename to app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt index 79643de7f..1d81b7565 100644 --- a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt @@ -1,35 +1,33 @@ package eu.kanade.domain.animeextension.interactor -import eu.kanade.core.util.asFlow -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.domain.animeextension.model.AnimeExtensions +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -typealias ExtensionSegregation = Triple, List, List> - -class GetAnimeExtensions( - private val preferences: PreferencesHelper, +class GetAnimeExtensionsByType( + private val preferences: SourcePreferences, private val extensionManager: AnimeExtensionManager, ) { - fun subscribe(): Flow { + fun subscribe(): Flow { val showNsfwSources = preferences.showNsfwSource().get() return combine( - preferences.enabledLanguages().asFlow(), - extensionManager.getInstalledExtensionsObservable().asFlow(), - extensionManager.getUntrustedExtensionsObservable().asFlow(), - extensionManager.getAvailableExtensionsObservable().asFlow(), + preferences.enabledLanguages().changes(), + extensionManager.installedExtensionsFlow, + extensionManager.untrustedExtensionsFlow, + extensionManager.availableExtensionsFlow, ) { _activeLanguages, _installed, _untrusted, _available -> - - val installed = _installed - .filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) } + val (updates, installed) = _installed + .filter { (showNsfwSources || it.isNsfw.not()) } .sortedWith( compareBy { it.isObsolete.not() } .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, ) + .partition { it.hasUpdate } val untrusted = _untrusted .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) @@ -38,11 +36,11 @@ class GetAnimeExtensions( .filter { extension -> _installed.none { it.pkgName == extension.pkgName } && _untrusted.none { it.pkgName == extension.pkgName } && - extension.lang in _activeLanguages && (showNsfwSources || extension.isNsfw.not()) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - Triple(installed, untrusted, available) + AnimeExtensions(updates, installed, available, untrusted) } } } diff --git a/app/src/main/java/eu/kanade/domain/animeextension/model/Extensions.kt b/app/src/main/java/eu/kanade/domain/animeextension/model/Extensions.kt new file mode 100644 index 000000000..d685b9e39 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/model/Extensions.kt @@ -0,0 +1,10 @@ +package eu.kanade.domain.animeextension.model + +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension + +data class AnimeExtensions( + val updates: List, + val installed: List, + val available: List, + val untrusted: List, +) diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAllAnimeHistory.kt similarity index 89% rename from app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt rename to app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAllAnimeHistory.kt index 066fba4e5..b9a49bf51 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAllAnimeHistory.kt @@ -2,7 +2,7 @@ package eu.kanade.domain.animehistory.interactor import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository -class DeleteAnimeHistoryTable( +class DeleteAllAnimeHistory( private val repository: AnimeHistoryRepository, ) { diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt index af6e4cbfe..6a72235cd 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt @@ -1,8 +1,5 @@ package eu.kanade.domain.animehistory.interactor -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import kotlinx.coroutines.flow.Flow @@ -11,11 +8,7 @@ class GetAnimeHistory( private val repository: AnimeHistoryRepository, ) { - fun subscribe(query: String): Flow> { - return Pager( - PagingConfig(pageSize = 25), - ) { - repository.getHistory(query) - }.flow + fun subscribe(query: String): Flow> { + return repository.getHistory(query) } } diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisode.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisode.kt index 9f96b2a6f..013ed3a70 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisode.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisode.kt @@ -1,18 +1,51 @@ package eu.kanade.domain.animehistory.interactor +import eu.kanade.domain.anime.interactor.GetAnime +import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository +import eu.kanade.domain.episode.interactor.GetEpisode +import eu.kanade.domain.episode.interactor.GetEpisodeByAnimeId import eu.kanade.domain.episode.model.Episode +import eu.kanade.tachiyomi.util.episode.getEpisodeSort class GetNextEpisode( - private val repository: AnimeHistoryRepository, + private val getEpisode: GetEpisode, + private val getEpisodeByAnimeId: GetEpisodeByAnimeId, + private val getAnime: GetAnime, + private val historyRepository: AnimeHistoryRepository, ) { - suspend fun await(animeId: Long, episodeId: Long): Episode? { - return repository.getNextEpisode(animeId, episodeId) + suspend fun await(): Episode? { + val history = historyRepository.getLastHistory() ?: return null + return await(history.animeId, history.episodeId) } - suspend fun await(): Episode? { - val history = repository.getLastHistory() ?: return null - return repository.getNextEpisode(history.animeId, history.episodeId) + suspend fun await(animeId: Long, episodeId: Long): Episode? { + val episode = getEpisode.await(episodeId) ?: return null + val anime = getAnime.await(animeId) ?: return null + + if (!episode.seen) return episode + + val episodes = getEpisodeByAnimeId.await(animeId) + .sortedWith(getEpisodeSort(anime, sortDescending = false)) + + val currEpisodeIndex = episodes.indexOfFirst { episode.id == it.id } + return when (anime.sorting) { + Anime.EPISODE_SORTING_SOURCE -> episodes.getOrNull(currEpisodeIndex + 1) + Anime.EPISODE_SORTING_NUMBER -> { + val episodeNumber = episode.episodeNumber + + ((currEpisodeIndex + 1) until episodes.size) + .map { episodes[it] } + .firstOrNull { + it.episodeNumber > episodeNumber && it.episodeNumber <= episodeNumber + 1 + } + } + Anime.EPISODE_SORTING_UPLOAD_DATE -> { + episodes.drop(currEpisodeIndex + 1) + .firstOrNull { it.dateUpload >= episode.dateUpload } + } + else -> throw NotImplementedError("Invalid episode sorting method: ${anime.sorting}") + } } } diff --git a/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt b/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt index d877323dd..3d3a30e08 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt @@ -1,18 +1,15 @@ package eu.kanade.domain.animehistory.repository -import androidx.paging.PagingSource import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations -import eu.kanade.domain.episode.model.Episode +import kotlinx.coroutines.flow.Flow interface AnimeHistoryRepository { - fun getHistory(query: String): PagingSource + fun getHistory(query: String): Flow> suspend fun getLastHistory(): AnimeHistoryWithRelations? - suspend fun getNextEpisode(animeId: Long, episodeId: Long): Episode? - suspend fun resetHistory(historyId: Long) suspend fun resetHistoryByAnimeId(animeId: Long) diff --git a/app/src/main/java/eu/kanade/domain/animelib/model/AnimelibAnime.kt b/app/src/main/java/eu/kanade/domain/animelib/model/AnimelibAnime.kt new file mode 100644 index 000000000..73afdc2f3 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animelib/model/AnimelibAnime.kt @@ -0,0 +1,24 @@ +package eu.kanade.domain.animelib.model + +import eu.kanade.domain.anime.model.Anime + +data class AnimelibAnime( + val anime: Anime, + val category: Long, + val totalEpisodes: Long, + val seenCount: Long, + val bookmarkCount: Long, + val latestUpload: Long, + val episodeFetchedAt: Long, + val lastSeen: Long, +) { + val id: Long = anime.id + + val unseenCount + get() = totalEpisodes - seenCount + + val hasBookmarks + get() = bookmarkCount > 0 + + val hasStarted = seenCount > 0 +} diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourceData.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourceData.kt deleted file mode 100644 index 1c0957d07..000000000 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourceData.kt +++ /dev/null @@ -1,20 +0,0 @@ -package eu.kanade.domain.animesource.interactor - -import eu.kanade.domain.animesource.model.AnimeSourceData -import eu.kanade.domain.animesource.repository.AnimeSourceRepository -import eu.kanade.tachiyomi.util.system.logcat -import logcat.LogPriority - -class GetAnimeSourceData( - private val repository: AnimeSourceRepository, -) { - - suspend fun await(id: Long): AnimeSourceData? { - return try { - repository.getAnimeSourceData(id) - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - null - } - } -} diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt index c990e713b..e4a37ba93 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.animesource.interactor import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.repository.AnimeSourceRepository import eu.kanade.domain.source.interactor.SetMigrateSorting -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.domain.source.service.SourcePreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import java.text.Collator @@ -12,13 +12,13 @@ import java.util.Locale class GetAnimeSourcesWithFavoriteCount( private val repository: AnimeSourceRepository, - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { fun subscribe(): Flow>> { return combine( - preferences.migrationSortingDirection().asFlow(), - preferences.migrationSortingMode().asFlow(), + preferences.migrationSortingDirection().changes(), + preferences.migrationSortingMode().changes(), repository.getSourcesWithFavoriteCount(), ) { direction, mode, list -> list.sortedWith(sortFn(direction, mode)) diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithNonLibraryAnime.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithNonLibraryAnime.kt index 294bec5c9..edffc1e13 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithNonLibraryAnime.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithNonLibraryAnime.kt @@ -1,14 +1,14 @@ package eu.kanade.domain.animesource.interactor +import eu.kanade.domain.animesource.model.AnimeSourceWithCount import eu.kanade.domain.animesource.repository.AnimeSourceRepository -import eu.kanade.tachiyomi.animesource.AnimeSource import kotlinx.coroutines.flow.Flow class GetAnimeSourcesWithNonLibraryAnime( private val repository: AnimeSourceRepository, ) { - fun subscribe(): Flow>> { + fun subscribe(): Flow> { return repository.getSourcesWithNonLibraryAnime() } } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetEnabledAnimeSources.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetEnabledAnimeSources.kt index f0132c632..edea8dd9e 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetEnabledAnimeSources.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetEnabledAnimeSources.kt @@ -4,23 +4,23 @@ import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.Pin import eu.kanade.domain.animesource.model.Pins import eu.kanade.domain.animesource.repository.AnimeSourceRepository +import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.animesource.LocalAnimeSource -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged class GetEnabledAnimeSources( private val repository: AnimeSourceRepository, - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { fun subscribe(): Flow> { return combine( - preferences.pinnedAnimeSources().asFlow(), - preferences.enabledLanguages().asFlow(), - preferences.disabledAnimeSources().asFlow(), - preferences.lastUsedAnimeSource().asFlow(), + preferences.pinnedAnimeSources().changes(), + preferences.enabledLanguages().changes(), + preferences.disabledAnimeSources().changes(), + preferences.lastUsedAnimeSource().changes(), repository.getSources(), ) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, sources -> val duplicatePins = preferences.duplicatePinnedSources().get() diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt index ca209a844..5e8ab9567 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt @@ -2,20 +2,20 @@ package eu.kanade.domain.animesource.interactor import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.repository.AnimeSourceRepository -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.util.system.LocaleHelper import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine class GetLanguagesWithAnimeSources( private val repository: AnimeSourceRepository, - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { fun subscribe(): Flow>> { return combine( - preferences.enabledLanguages().asFlow(), - preferences.disabledSources().asFlow(), + preferences.enabledLanguages().changes(), + preferences.disabledSources().changes(), repository.getOnlineSources(), ) { enabledLanguage, disabledSource, onlineSources -> val sortedSources = onlineSources.sortedWith( diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetRemoteAnime.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetRemoteAnime.kt new file mode 100644 index 000000000..2c612287d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetRemoteAnime.kt @@ -0,0 +1,23 @@ +package eu.kanade.domain.animesource.interactor + +import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType +import eu.kanade.domain.animesource.repository.AnimeSourceRepository +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList + +class GetRemoteAnime( + private val repository: AnimeSourceRepository, +) { + + fun subscribe(sourceId: Long, query: String, filterList: AnimeFilterList): AnimeSourcePagingSourceType { + return when (query) { + QUERY_POPULAR -> repository.getPopular(sourceId) + QUERY_LATEST -> repository.getLatest(sourceId) + else -> repository.search(sourceId, query, filterList) + } + } + + companion object { + const val QUERY_POPULAR = "eu.kanade.domain.animesource.interactor.POPULAR" + const val QUERY_LATEST = "eu.kanade.domain.animesource.interactor.LATEST" + } +} diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt index e33a1e756..04b8c07bf 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt @@ -1,23 +1,24 @@ package eu.kanade.domain.animesource.interactor import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.plusAssign +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.core.preference.getAndSet class ToggleAnimeSource( - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { - fun await(source: AnimeSource, enable: Boolean = source.id.toString() in preferences.disabledAnimeSources().get()) { + fun await(source: AnimeSource, enable: Boolean = isEnabled(source.id)) { await(source.id, enable) } - fun await(sourceId: Long, enable: Boolean = sourceId.toString() in preferences.disabledAnimeSources().get()) { - if (enable) { - preferences.disabledAnimeSources() -= sourceId.toString() - } else { - preferences.disabledAnimeSources() += sourceId.toString() + fun await(sourceId: Long, enable: Boolean = isEnabled(sourceId)) { + preferences.disabledAnimeSources().getAndSet { disabled -> + if (enable) disabled.minus("$sourceId") else disabled.plus("$sourceId") } } + + private fun isEnabled(sourceId: Long): Boolean { + return sourceId.toString() in preferences.disabledAnimeSources().get() + } } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt index d07d8d891..20e8b1b65 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt @@ -1,20 +1,17 @@ package eu.kanade.domain.animesource.interactor import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.plusAssign +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.core.preference.getAndSet class ToggleAnimeSourcePin( - private val preferences: PreferencesHelper, + private val preferences: SourcePreferences, ) { fun await(source: AnimeSource) { val isPinned = source.id.toString() in preferences.pinnedAnimeSources().get() - if (isPinned) { - preferences.pinnedAnimeSources() -= source.id.toString() - } else { - preferences.pinnedAnimeSources() += source.id.toString() + preferences.pinnedAnimeSources().getAndSet { pinned -> + if (isPinned) pinned.minus("${source.id}") else pinned.plus("${source.id}") } } } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/UpsertAnimeSourceData.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/UpsertAnimeSourceData.kt deleted file mode 100644 index 9d6239dc9..000000000 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/UpsertAnimeSourceData.kt +++ /dev/null @@ -1,19 +0,0 @@ -package eu.kanade.domain.animesource.interactor - -import eu.kanade.domain.animesource.model.AnimeSourceData -import eu.kanade.domain.animesource.repository.AnimeSourceRepository -import eu.kanade.tachiyomi.util.system.logcat -import logcat.LogPriority - -class UpsertAnimeSourceData( - private val repository: AnimeSourceRepository, -) { - - suspend fun await(sourceData: AnimeSourceData) { - try { - repository.upsertAnimeSourceData(sourceData.id, sourceData.lang, sourceData.name) - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - } - } -} diff --git a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceData.kt b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceData.kt index 16fa7e959..1363250d6 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceData.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceData.kt @@ -4,4 +4,7 @@ data class AnimeSourceData( val id: Long, val lang: String, val name: String, -) +) { + + val isMissingInfo: Boolean = name.isBlank() || lang.isBlank() +} diff --git a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourcePagingSourceType.kt b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourcePagingSourceType.kt new file mode 100644 index 000000000..0441a4246 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourcePagingSourceType.kt @@ -0,0 +1,6 @@ +package eu.kanade.domain.animesource.model + +import androidx.paging.PagingSource +import eu.kanade.tachiyomi.animesource.model.SAnime + +typealias AnimeSourcePagingSourceType = PagingSource diff --git a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceWithCount.kt b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceWithCount.kt new file mode 100644 index 000000000..91b15ce2a --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSourceWithCount.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.animesource.model + +data class AnimeSourceWithCount( + val source: AnimeSource, + val count: Long, +) { + + val id: Long + get() = source.id + + val name: String + get() = source.name +} diff --git a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt b/app/src/main/java/eu/kanade/domain/animesource/model/tachiyomi/animesource/AnimeSource.kt similarity index 90% rename from app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt rename to app/src/main/java/eu/kanade/domain/animesource/model/tachiyomi/animesource/AnimeSource.kt index 247ef9b64..c79bdb59e 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/model/tachiyomi/animesource/AnimeSource.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.animesource.model import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap -import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -17,8 +17,11 @@ data class AnimeSource( val isUsedLast: Boolean = false, ) { - val nameWithLanguage: String - get() = "$name (${lang.uppercase()})" + val visualName: String + get() = when { + lang.isEmpty() -> name + else -> "$name (${lang.uppercase()})" + } val icon: ImageBitmap? get() { diff --git a/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceDataRepository.kt b/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceDataRepository.kt new file mode 100644 index 000000000..6959c34eb --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceDataRepository.kt @@ -0,0 +1,12 @@ +package eu.kanade.domain.animesource.repository + +import eu.kanade.domain.animesource.model.AnimeSourceData +import kotlinx.coroutines.flow.Flow + +interface AnimeSourceDataRepository { + fun subscribeAll(): Flow> + + suspend fun getSourceData(id: Long): AnimeSourceData? + + suspend fun upsertSourceData(id: Long, lang: String, name: String) +} diff --git a/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceRepository.kt b/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceRepository.kt index e05abb7e5..4514af0e6 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceRepository.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/repository/AnimeSourceRepository.kt @@ -1,9 +1,10 @@ package eu.kanade.domain.animesource.repository import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType +import eu.kanade.domain.animesource.model.AnimeSourceWithCount +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import kotlinx.coroutines.flow.Flow -import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource interface AnimeSourceRepository { @@ -13,9 +14,11 @@ interface AnimeSourceRepository { fun getSourcesWithFavoriteCount(): Flow>> - fun getSourcesWithNonLibraryAnime(): Flow>> + fun getSourcesWithNonLibraryAnime(): Flow> - suspend fun getAnimeSourceData(id: Long): AnimeSourceData? + fun search(sourceId: Long, query: String, filterList: AnimeFilterList): AnimeSourcePagingSourceType - suspend fun upsertAnimeSourceData(id: Long, lang: String, name: String) + fun getPopular(sourceId: Long): AnimeSourcePagingSourceType + + fun getLatest(sourceId: Long): AnimeSourcePagingSourceType } diff --git a/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetAnimeTracks.kt b/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetAnimeTracks.kt index 4605c93b3..ec96a238d 100644 --- a/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetAnimeTracks.kt +++ b/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetAnimeTracks.kt @@ -19,10 +19,6 @@ class GetAnimeTracks( } } - fun subscribe(): Flow> { - return animetrackRepository.getAnimeTracksAsFlow() - } - fun subscribe(animeId: Long): Flow> { return animetrackRepository.getAnimeTracksByAnimeIdAsFlow(animeId) } diff --git a/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetTracksPerAnime.kt b/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetTracksPerAnime.kt new file mode 100644 index 000000000..ef87bf6d4 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animetrack/interactor/GetTracksPerAnime.kt @@ -0,0 +1,20 @@ +package eu.kanade.domain.animetrack.interactor + +import eu.kanade.domain.animetrack.repository.AnimeTrackRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetTracksPerAnime( + private val trackRepository: AnimeTrackRepository, +) { + + fun subscribe(): Flow>> { + return trackRepository.getAnimeTracksAsFlow().map { tracks -> + tracks + .groupBy { it.animeId } + .mapValues { entry -> + entry.value.map { it.syncId } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeupdates/interactor/GetAnimeUpdates.kt b/app/src/main/java/eu/kanade/domain/animeupdates/interactor/GetAnimeUpdates.kt new file mode 100644 index 000000000..e53afbed4 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeupdates/interactor/GetAnimeUpdates.kt @@ -0,0 +1,24 @@ +package eu.kanade.domain.animeupdates.interactor + +import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations +import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository +import eu.kanade.domain.library.service.LibraryPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import java.util.Calendar + +class GetAnimeUpdates( + private val repository: AnimeUpdatesRepository, + private val preferences: LibraryPreferences, +) { + + fun subscribe(calendar: Calendar): Flow> = subscribe(calendar.time.time) + + fun subscribe(after: Long): Flow> { + return repository.subscribeAll(after) + .onEach { updates -> + // Set unread chapter count for bottom bar badge + preferences.unseenUpdatesCount().set(updates.count { !it.seen }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeupdates/model/AnimeUpdatesWithRelations.kt b/app/src/main/java/eu/kanade/domain/animeupdates/model/AnimeUpdatesWithRelations.kt new file mode 100644 index 000000000..4a5928221 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeupdates/model/AnimeUpdatesWithRelations.kt @@ -0,0 +1,16 @@ +package eu.kanade.domain.animeupdates.model + +import eu.kanade.domain.manga.model.MangaCover + +data class AnimeUpdatesWithRelations( + val animeId: Long, + val animeTitle: String, + val episodeId: Long, + val episodeName: String, + val scanlator: String?, + val seen: Boolean, + val bookmark: Boolean, + val sourceId: Long, + val dateFetch: Long, + val coverData: MangaCover, +) diff --git a/app/src/main/java/eu/kanade/domain/animeupdates/repository/AnimeUpdatesRepository.kt b/app/src/main/java/eu/kanade/domain/animeupdates/repository/AnimeUpdatesRepository.kt new file mode 100644 index 000000000..cd29b4347 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeupdates/repository/AnimeUpdatesRepository.kt @@ -0,0 +1,9 @@ +package eu.kanade.domain.animeupdates.repository + +import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations +import kotlinx.coroutines.flow.Flow + +interface AnimeUpdatesRepository { + + fun subscribeAll(after: Long): Flow> +} diff --git a/app/src/main/java/eu/kanade/domain/backup/service/BackupPreferences.kt b/app/src/main/java/eu/kanade/domain/backup/service/BackupPreferences.kt index 71c35745a..813a4da4c 100644 --- a/app/src/main/java/eu/kanade/domain/backup/service/BackupPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/backup/service/BackupPreferences.kt @@ -2,6 +2,10 @@ package eu.kanade.domain.backup.service import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.provider.FolderProvider +import eu.kanade.tachiyomi.data.preference.FLAG_CATEGORIES +import eu.kanade.tachiyomi.data.preference.FLAG_CHAPTERS +import eu.kanade.tachiyomi.data.preference.FLAG_HISTORY +import eu.kanade.tachiyomi.data.preference.FLAG_TRACK class BackupPreferences( private val folderProvider: FolderProvider, @@ -13,4 +17,6 @@ class BackupPreferences( fun numberOfBackups() = preferenceStore.getInt("backup_slots", 2) fun backupInterval() = preferenceStore.getInt("backup_interval", 12) + + fun backupFlags() = preferenceStore.getStringSet("backup_flags", setOf(FLAG_CATEGORIES, FLAG_CHAPTERS, FLAG_HISTORY, FLAG_TRACK)) } diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index 892bb24b3..31ad332ee 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -5,8 +5,6 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.isPreviewBuildType -import eu.kanade.tachiyomi.util.system.isReleaseBuildType class BasePreferences( val context: Context, @@ -26,5 +24,6 @@ class BasePreferences( if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER, ) - fun acraEnabled() = preferenceStore.getBoolean("acra.enable", isPreviewBuildType || isReleaseBuildType) + // acra is disabled + fun acraEnabled() = preferenceStore.getBoolean("acra.enable", false) } diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/CreateAnimeCategoryWithName.kt b/app/src/main/java/eu/kanade/domain/category/interactor/CreateAnimeCategoryWithName.kt new file mode 100644 index 000000000..a8c63e9ed --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/CreateAnimeCategoryWithName.kt @@ -0,0 +1,52 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.category.model.anyWithName +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.domain.library.service.LibraryPreferences +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class CreateAnimeCategoryWithName( + private val categoryRepository: CategoryRepositoryAnime, + private val preferences: LibraryPreferences, +) { + + private val initialFlags: Long + get() { + val sort = preferences.librarySortingMode().get() + return preferences.libraryDisplayMode().get().flag or + sort.type.flag or + sort.direction.flag + } + + suspend fun await(name: String): Result = withNonCancellableContext { + val categories = categoryRepository.getAll() + if (categories.anyWithName(name)) { + return@withNonCancellableContext Result.NameAlreadyExistsError + } + + val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0 + val newCategory = Category( + id = 0, + name = name, + order = nextOrder, + flags = initialFlags, + ) + + try { + categoryRepository.insert(newCategory) + Result.Success + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + Result.InternalError(e) + } + } + + sealed class Result { + object Success : Result() + object NameAlreadyExistsError : Result() + data class InternalError(val error: Throwable) : Result() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/DeleteAnimeCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/DeleteAnimeCategory.kt new file mode 100644 index 000000000..2c7f6e827 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/DeleteAnimeCategory.kt @@ -0,0 +1,42 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.CategoryUpdate +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class DeleteAnimeCategory( + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await(categoryId: Long) = withNonCancellableContext { + try { + categoryRepository.delete(categoryId) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + return@withNonCancellableContext Result.InternalError(e) + } + + val categories = categoryRepository.getAll() + val updates = categories.mapIndexed { index, category -> + CategoryUpdate( + id = category.id, + order = index.toLong(), + ) + } + + try { + categoryRepository.updatePartial(updates) + Result.Success + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + Result.InternalError(e) + } + } + + sealed class Result { + object Success : Result() + data class InternalError(val error: Throwable) : Result() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategoryAnime.kt b/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategoryAnime.kt deleted file mode 100644 index 5eaf68359..000000000 --- a/app/src/main/java/eu/kanade/domain/category/interactor/DeleteCategoryAnime.kt +++ /dev/null @@ -1,12 +0,0 @@ -package eu.kanade.domain.category.interactor - -import eu.kanade.domain.category.repository.CategoryRepositoryAnime - -class DeleteCategoryAnime( - private val categoryRepository: CategoryRepositoryAnime, -) { - - suspend fun await(categoryId: Long) { - categoryRepository.delete(categoryId) - } -} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/GetCategoriesAnime.kt b/app/src/main/java/eu/kanade/domain/category/interactor/GetAnimeCategories.kt similarity index 96% rename from app/src/main/java/eu/kanade/domain/category/interactor/GetCategoriesAnime.kt rename to app/src/main/java/eu/kanade/domain/category/interactor/GetAnimeCategories.kt index 787401620..d62094a1f 100644 --- a/app/src/main/java/eu/kanade/domain/category/interactor/GetCategoriesAnime.kt +++ b/app/src/main/java/eu/kanade/domain/category/interactor/GetAnimeCategories.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.repository.CategoryRepositoryAnime import kotlinx.coroutines.flow.Flow -class GetCategoriesAnime( +class GetAnimeCategories( private val categoryRepository: CategoryRepositoryAnime, ) { diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategoryAnime.kt b/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategoryAnime.kt deleted file mode 100644 index 8e83e379c..000000000 --- a/app/src/main/java/eu/kanade/domain/category/interactor/InsertCategoryAnime.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eu.kanade.domain.category.interactor - -import eu.kanade.domain.category.repository.CategoryRepositoryAnime - -class InsertCategoryAnime( - private val categoryRepository: CategoryRepositoryAnime, -) { - - suspend fun await(name: String, order: Long): Result { - return try { - categoryRepository.insert(name, order) - Result.Success - } catch (e: Exception) { - Result.Error(e) - } - } - - sealed class Result { - object Success : Result() - data class Error(val error: Exception) : Result() - } -} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/RenameAnimeCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/RenameAnimeCategory.kt new file mode 100644 index 000000000..95d5b9ac2 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/RenameAnimeCategory.kt @@ -0,0 +1,42 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.category.model.CategoryUpdate +import eu.kanade.domain.category.model.anyWithName +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class RenameAnimeCategory( + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await(categoryId: Long, name: String) = withNonCancellableContext { + val categories = categoryRepository.getAll() + if (categories.anyWithName(name)) { + return@withNonCancellableContext Result.NameAlreadyExistsError + } + + val update = CategoryUpdate( + id = categoryId, + name = name, + ) + + try { + categoryRepository.updatePartial(update) + Result.Success + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + Result.InternalError(e) + } + } + + suspend fun await(category: Category, name: String) = await(category.id, name) + + sealed class Result { + object Success : Result() + object NameAlreadyExistsError : Result() + data class InternalError(val error: Throwable) : Result() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/ReorderAnimeCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/ReorderAnimeCategory.kt new file mode 100644 index 000000000..ec738431d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/ReorderAnimeCategory.kt @@ -0,0 +1,50 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.category.model.CategoryUpdate +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class ReorderAnimeCategory( + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await(categoryId: Long, newPosition: Int) = withNonCancellableContext { + val categories = categoryRepository.getAll().filterNot(Category::isSystemCategory) + + val currentIndex = categories.indexOfFirst { it.id == categoryId } + if (currentIndex == newPosition) { + return@withNonCancellableContext Result.Unchanged + } + + val reorderedCategories = categories.toMutableList() + val reorderedCategory = reorderedCategories.removeAt(currentIndex) + reorderedCategories.add(newPosition, reorderedCategory) + + val updates = reorderedCategories.mapIndexed { index, category -> + CategoryUpdate( + id = category.id, + order = index.toLong(), + ) + } + + try { + categoryRepository.updatePartial(updates) + Result.Success + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + Result.InternalError(e) + } + } + + suspend fun await(category: Category, newPosition: Long): Result = + await(category.id, newPosition.toInt()) + + sealed class Result { + object Success : Result() + object Unchanged : Result() + data class InternalError(val error: Throwable) : Result() + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/ResetAnimeCategoryFlags.kt b/app/src/main/java/eu/kanade/domain/category/interactor/ResetAnimeCategoryFlags.kt new file mode 100644 index 000000000..4fc70149c --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/ResetAnimeCategoryFlags.kt @@ -0,0 +1,17 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.domain.library.model.plus +import eu.kanade.domain.library.service.LibraryPreferences + +class ResetAnimeCategoryFlags( + private val preferences: LibraryPreferences, + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await() { + val display = preferences.libraryDisplayMode().get() + val sort = preferences.librarySortingMode().get() + categoryRepository.updateAllFlags(display + sort.type + sort.direction) + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/SetDisplayModeForAnimeCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/SetDisplayModeForAnimeCategory.kt new file mode 100644 index 000000000..de6a1a5a8 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/SetDisplayModeForAnimeCategory.kt @@ -0,0 +1,34 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.category.model.CategoryUpdate +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.domain.library.model.LibraryDisplayMode +import eu.kanade.domain.library.model.plus +import eu.kanade.domain.library.service.LibraryPreferences + +class SetDisplayModeForAnimeCategory( + private val preferences: LibraryPreferences, + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await(categoryId: Long, display: LibraryDisplayMode) { + val category = categoryRepository.get(categoryId) ?: return + val flags = category.flags + display + if (preferences.categorizedDisplaySettings().get()) { + categoryRepository.updatePartial( + CategoryUpdate( + id = category.id, + flags = flags, + ), + ) + } else { + preferences.libraryDisplayMode().set(display) + categoryRepository.updateAllFlags(flags) + } + } + + suspend fun await(category: Category, display: LibraryDisplayMode) { + await(category.id, display) + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/SetSortModeForAnimeCategory.kt b/app/src/main/java/eu/kanade/domain/category/interactor/SetSortModeForAnimeCategory.kt new file mode 100644 index 000000000..b8014d1ac --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/category/interactor/SetSortModeForAnimeCategory.kt @@ -0,0 +1,34 @@ +package eu.kanade.domain.category.interactor + +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.category.model.CategoryUpdate +import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.domain.library.model.LibrarySort +import eu.kanade.domain.library.model.plus +import eu.kanade.domain.library.service.LibraryPreferences + +class SetSortModeForAnimeCategory( + private val preferences: LibraryPreferences, + private val categoryRepository: CategoryRepositoryAnime, +) { + + suspend fun await(categoryId: Long, type: LibrarySort.Type, direction: LibrarySort.Direction) { + val category = categoryRepository.get(categoryId) ?: return + val flags = category.flags + type + direction + if (preferences.categorizedDisplaySettings().get()) { + categoryRepository.updatePartial( + CategoryUpdate( + id = category.id, + flags = flags, + ), + ) + } else { + preferences.librarySortingMode().set(LibrarySort(type, direction)) + categoryRepository.updateAllFlags(flags) + } + } + + suspend fun await(category: Category, type: LibrarySort.Type, direction: LibrarySort.Direction) { + await(category.id, type, direction) + } +} diff --git a/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategoryAnime.kt b/app/src/main/java/eu/kanade/domain/category/interactor/UpdateAnimeCategory.kt similarity index 65% rename from app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategoryAnime.kt rename to app/src/main/java/eu/kanade/domain/category/interactor/UpdateAnimeCategory.kt index 77e0fbbc9..4e13a4a34 100644 --- a/app/src/main/java/eu/kanade/domain/category/interactor/UpdateCategoryAnime.kt +++ b/app/src/main/java/eu/kanade/domain/category/interactor/UpdateAnimeCategory.kt @@ -2,14 +2,15 @@ package eu.kanade.domain.category.interactor import eu.kanade.domain.category.model.CategoryUpdate import eu.kanade.domain.category.repository.CategoryRepositoryAnime +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext -class UpdateCategoryAnime( +class UpdateAnimeCategory( private val categoryRepository: CategoryRepositoryAnime, ) { - suspend fun await(payload: CategoryUpdate): Result { - return try { - categoryRepository.update(payload) + suspend fun await(payload: CategoryUpdate): Result = withNonCancellableContext { + try { + categoryRepository.updatePartial(payload) Result.Success } catch (e: Exception) { Result.Error(e) diff --git a/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepositoryAnime.kt b/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepositoryAnime.kt index 16d5ae3ce..9b35c18b4 100644 --- a/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepositoryAnime.kt +++ b/app/src/main/java/eu/kanade/domain/category/repository/CategoryRepositoryAnime.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.Flow interface CategoryRepositoryAnime { + suspend fun get(id: Long): Category? + suspend fun getAll(): List fun getAllAsFlow(): Flow> @@ -14,13 +16,13 @@ interface CategoryRepositoryAnime { fun getCategoriesByAnimeIdAsFlow(animeId: Long): Flow> - @Throws(DuplicateNameException::class) - suspend fun insert(name: String, order: Long) + suspend fun insert(category: Category) - @Throws(DuplicateNameException::class) - suspend fun update(payload: CategoryUpdate) + suspend fun updatePartial(update: CategoryUpdate) + + suspend fun updatePartial(updates: List) + + suspend fun updateAllFlags(flags: Long?) suspend fun delete(categoryId: Long) - - suspend fun checkDuplicateName(name: String): Boolean } diff --git a/app/src/main/java/eu/kanade/domain/download/service/DownloadPreferences.kt b/app/src/main/java/eu/kanade/domain/download/service/DownloadPreferences.kt index 76b0465a6..32e5e5f32 100644 --- a/app/src/main/java/eu/kanade/domain/download/service/DownloadPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/download/service/DownloadPreferences.kt @@ -12,12 +12,18 @@ class DownloadPreferences( fun downloadOnlyOverWifi() = preferenceStore.getBoolean("pref_download_only_over_wifi_key", true) + fun useExternalDownloader() = preferenceStore.getBoolean("use_external_downloader", false) + + fun externalDownloaderSelection() = preferenceStore.getString("external_downloader_selection", "") + fun saveChaptersAsCBZ() = preferenceStore.getBoolean("save_chapter_as_cbz", true) fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", false) fun autoDownloadWhileReading() = preferenceStore.getInt("auto_download_while_reading", 0) + fun autoDownloadWhileWatching() = preferenceStore.getInt("auto_download_while_watching", 0) + fun removeAfterReadSlots() = preferenceStore.getInt("remove_after_read_slots", -1) fun removeAfterMarkedAsRead() = preferenceStore.getBoolean("pref_remove_after_marked_as_read_key", false) @@ -25,10 +31,14 @@ class DownloadPreferences( fun removeBookmarkedChapters() = preferenceStore.getBoolean("pref_remove_bookmarked", false) fun removeExcludeCategories() = preferenceStore.getStringSet("remove_exclude_categories", emptySet()) + fun removeExcludeAnimeCategories() = preferenceStore.getStringSet("remove_exclude_anime_categories", emptySet()) fun downloadNewChapters() = preferenceStore.getBoolean("download_new", false) + fun downloadNewEpisodes() = preferenceStore.getBoolean("download_new_episode", false) fun downloadNewChapterCategories() = preferenceStore.getStringSet("download_new_categories", emptySet()) + fun downloadNewEpisodeCategories() = preferenceStore.getStringSet("download_new_anime_categories", emptySet()) fun downloadNewChapterCategoriesExclude() = preferenceStore.getStringSet("download_new_categories_exclude", emptySet()) + fun downloadNewEpisodeCategoriesExclude() = preferenceStore.getStringSet("download_new_anime_categories_exclude", emptySet()) } diff --git a/app/src/main/java/eu/kanade/domain/episode/interactor/SetDefaultEpisodeSettings.kt b/app/src/main/java/eu/kanade/domain/episode/interactor/SetDefaultEpisodeSettings.kt new file mode 100644 index 000000000..9b08c211b --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/episode/interactor/SetDefaultEpisodeSettings.kt @@ -0,0 +1,36 @@ +package eu.kanade.domain.episode.interactor + +import eu.kanade.domain.anime.interactor.GetAnimeFavorites +import eu.kanade.domain.anime.interactor.SetAnimeEpisodeFlags +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.library.service.LibraryPreferences +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext + +class SetAnimeDefaultEpisodeFlags( + private val libraryPreferences: LibraryPreferences, + private val setAnimeEpisodeFlags: SetAnimeEpisodeFlags, + private val getFavorites: GetAnimeFavorites, +) { + + suspend fun await(anime: Anime) { + withNonCancellableContext { + with(libraryPreferences) { + setAnimeEpisodeFlags.awaitSetAllFlags( + animeId = anime.id, + unseenFilter = filterEpisodeBySeen().get(), + downloadedFilter = filterEpisodeByDownloaded().get(), + bookmarkedFilter = filterEpisodeByBookmarked().get(), + sortingMode = sortEpisodeBySourceOrNumber().get(), + sortingDirection = sortEpisodeByAscendingOrDescending().get(), + displayMode = displayEpisodeByNameOrNumber().get(), + ) + } + } + } + + suspend fun awaitAll() { + withNonCancellableContext { + getFavorites.await().forEach { await(it) } + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/episode/interactor/SetSeenStatus.kt b/app/src/main/java/eu/kanade/domain/episode/interactor/SetSeenStatus.kt index d4ccf0513..ec9bda122 100644 --- a/app/src/main/java/eu/kanade/domain/episode/interactor/SetSeenStatus.kt +++ b/app/src/main/java/eu/kanade/domain/episode/interactor/SetSeenStatus.kt @@ -3,17 +3,16 @@ package eu.kanade.domain.episode.interactor import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.repository.AnimeRepository import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload +import eu.kanade.domain.download.service.DownloadPreferences import eu.kanade.domain.episode.model.Episode import eu.kanade.domain.episode.model.EpisodeUpdate import eu.kanade.domain.episode.repository.EpisodeRepository -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext import logcat.LogPriority class SetSeenStatus( - private val preferences: PreferencesHelper, + private val downloadPreferences: DownloadPreferences, private val deleteDownload: DeleteAnimeDownload, private val animeRepository: AnimeRepository, private val episodeRepository: EpisodeRepository, @@ -27,49 +26,44 @@ class SetSeenStatus( ) } - suspend fun await(seen: Boolean, vararg values: Episode): Result = withContext(NonCancellable) f@{ - val episodes = values.filterNot { it.seen == seen } - - if (episodes.isEmpty()) { - return@f Result.NoEpisodes - } - - val anime = episodes.fold(mutableSetOf()) { acc, episode -> - if (acc.all { it.id != episode.animeId }) { - acc += animeRepository.getAnimeById(episode.animeId) + suspend fun await(seen: Boolean, vararg episodes: Episode): Result = withNonCancellableContext { + val episodesToUpdate = episodes.filter { + when (seen) { + true -> !it.seen + false -> it.seen || it.lastSecondSeen > 0 } - acc + } + if (episodesToUpdate.isEmpty()) { + return@withNonCancellableContext Result.NoEpisodes } try { episodeRepository.updateAll( - episodes.map { episode -> - mapper(episode, seen) - }, + episodesToUpdate.map { mapper(it, seen) }, ) } catch (e: Exception) { logcat(LogPriority.ERROR, e) - return@f Result.InternalError(e) + return@withNonCancellableContext Result.InternalError(e) } - if (seen && preferences.removeAfterMarkedAsRead()) { - anime.forEach { anime -> - deleteDownload.awaitAll( - anime = anime, - values = episodes - .filter { anime.id == it.animeId } - .toTypedArray(), - ) - } + if (seen && downloadPreferences.removeAfterMarkedAsRead().get()) { + episodesToUpdate + .groupBy { it.animeId } + .forEach { (animeId, episodes) -> + deleteDownload.awaitAll( + anime = animeRepository.getAnimeById(animeId), + episodes = episodes.toTypedArray(), + ) + } } Result.Success } - suspend fun await(animeId: Long, seen: Boolean): Result = withContext(NonCancellable) f@{ - return@f await( + suspend fun await(animeId: Long, seen: Boolean): Result = withNonCancellableContext { + await( seen = seen, - values = episodeRepository + episodes = episodeRepository .getEpisodeByAnimeId(animeId) .toTypedArray(), ) diff --git a/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt b/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt index 31e9dee66..53f589da1 100644 --- a/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt @@ -1,16 +1,19 @@ package eu.kanade.domain.episode.interactor +import eu.kanade.data.episode.CleanupEpisodeName import eu.kanade.data.episode.NoEpisodesException import eu.kanade.domain.anime.interactor.UpdateAnime import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.episode.model.Episode +import eu.kanade.domain.episode.model.toDbEpisode import eu.kanade.domain.episode.model.toEpisodeUpdate import eu.kanade.domain.episode.repository.EpisodeRepository import eu.kanade.tachiyomi.animesource.AnimeSource -import eu.kanade.tachiyomi.animesource.LocalAnimeSource +import eu.kanade.tachiyomi.animesource.isLocal import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource -import eu.kanade.tachiyomi.data.download.AnimeDownloadManager +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadProvider import eu.kanade.tachiyomi.util.episode.EpisodeRecognition import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -20,6 +23,7 @@ import java.util.TreeSet class SyncEpisodesWithSource( private val downloadManager: AnimeDownloadManager = Injekt.get(), + private val downloadProvider: AnimeDownloadProvider = Injekt.get(), private val episodeRepository: EpisodeRepository = Injekt.get(), private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode = Injekt.get(), private val updateAnime: UpdateAnime = Injekt.get(), @@ -27,12 +31,20 @@ class SyncEpisodesWithSource( private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(), ) { + /** + * Method to synchronize db episodes with source ones + * + * @param rawSourceEpisodes the episodes from the source. + * @param anime the anime the episodes belong to. + * @param source the source the anime belongs to. + * @return Newly added episodes + */ suspend fun await( rawSourceEpisodes: List, anime: Anime, source: AnimeSource, - ): Pair, List> { - if (rawSourceEpisodes.isEmpty() && source.id != LocalAnimeSource.ID) { + ): List { + if (rawSourceEpisodes.isEmpty() && !source.isLocal()) { throw NoEpisodesException() } @@ -41,6 +53,7 @@ class SyncEpisodesWithSource( .mapIndexed { i, sEpisode -> Episode.create() .copyFromSEpisode(sEpisode) + .copy(name = CleanupEpisodeName.await(sEpisode.name, anime.title)) .copy(animeId = anime.id, sourceOrder = i.toLong()) } @@ -94,8 +107,11 @@ class SyncEpisodesWithSource( toAdd.add(toAddEpisode) } else { if (shouldUpdateDbEpisode.await(dbEpisode, episode)) { - if (dbEpisode.name != episode.name && downloadManager.isEpisodeDownloaded(dbEpisode.name, dbEpisode.scanlator, anime.title, anime.source)) { - downloadManager.renameEpisode(source, anime, dbEpisode, episode) + val shouldRenameEpisode = downloadProvider.isEpisodeDirNameChanged(dbEpisode, episode) && + downloadManager.isEpisodeDownloaded(dbEpisode.name, dbEpisode.scanlator, anime.title, anime.source) + + if (shouldRenameEpisode) { + downloadManager.renameEpisode(source, anime, dbEpisode.toDbEpisode(), episode.toDbEpisode()) } var toChangeEpisode = dbEpisode.copy( name = episode.name, @@ -113,18 +129,18 @@ class SyncEpisodesWithSource( // Return if there's nothing to add, delete or change, avoiding unnecessary db transactions. if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) { - return Pair(emptyList(), emptyList()) + return emptyList() } val reAdded = mutableListOf() val deletedEpisodeNumbers = TreeSet() val deletedSeenEpisodeNumbers = TreeSet() + val deletedBookmarkedEpisodeNumbers = TreeSet() toDelete.forEach { episode -> - if (episode.seen) { - deletedSeenEpisodeNumbers.add(episode.episodeNumber) - } + if (episode.seen) deletedSeenEpisodeNumbers.add(episode.episodeNumber) + if (episode.bookmark) deletedBookmarkedEpisodeNumbers.add(episode.episodeNumber) deletedEpisodeNumbers.add(episode.episodeNumber) } @@ -133,20 +149,19 @@ class SyncEpisodesWithSource( // Date fetch is set in such a way that the upper ones will have bigger value than the lower ones // Sources MUST return the episodes from most to less recent, which is common. - var itemCount = toAdd.size var updatedToAdd = toAdd.map { toAddItem -> var episode = toAddItem.copy(dateFetch = rightNow + itemCount--) - if (episode.isRecognizedNumber.not() && episode.episodeNumber !in deletedEpisodeNumbers) return@map episode + if (episode.isRecognizedNumber.not() || episode.episodeNumber !in deletedEpisodeNumbers) return@map episode - if (episode.episodeNumber in deletedSeenEpisodeNumbers) { - episode = episode.copy(seen = true) - } + episode = episode.copy( + seen = episode.episodeNumber in deletedSeenEpisodeNumbers, + bookmark = episode.episodeNumber in deletedBookmarkedEpisodeNumbers, + ) // Try to to use the fetch date of the original entry to not pollute 'Updates' tab - val oldDateFetch = deletedEpisodeNumberDateFetchMap[episode.episodeNumber] - oldDateFetch?.let { + deletedEpisodeNumberDateFetchMap[episode.episodeNumber]?.let { episode = episode.copy(dateFetch = it) } @@ -173,7 +188,8 @@ class SyncEpisodesWithSource( // Note that last_update actually represents last time the episode list changed at all updateAnime.awaitUpdateLastUpdate(anime.id) - @Suppress("ConvertArgumentToSet") // See tachiyomiorg/tachiyomi#6372. - return Pair(updatedToAdd.subtract(reAdded).toList(), toDelete.subtract(reAdded).toList()) + val reAddedUrls = reAdded.map { it.url }.toHashSet() + + return updatedToAdd.filterNot { it.url in reAddedUrls } } } diff --git a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt index e4b2349d6..a5e6e2469 100644 --- a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt +++ b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt @@ -38,7 +38,7 @@ data class Episode( url = sEpisode.url, dateUpload = sEpisode.date_upload, episodeNumber = sEpisode.episode_number, - scanlator = sEpisode.scanlator, + scanlator = sEpisode.scanlator?.ifBlank { null }, ) } diff --git a/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt b/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt index 2c3f9a632..53c7d411f 100644 --- a/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt +++ b/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt @@ -16,6 +16,8 @@ interface EpisodeRepository { suspend fun getEpisodeByAnimeId(animeId: Long): List + suspend fun getBookmarkedEpisodesByAnimeId(animeId: Long): List + suspend fun getEpisodeById(id: Long): Episode? fun getEpisodeByAnimeIdAsFlow(animeId: Long): Flow> diff --git a/app/src/main/java/eu/kanade/domain/library/service/LibraryPreferences.kt b/app/src/main/java/eu/kanade/domain/library/service/LibraryPreferences.kt index 18a8aac07..4963bc995 100644 --- a/app/src/main/java/eu/kanade/domain/library/service/LibraryPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/library/service/LibraryPreferences.kt @@ -1,5 +1,6 @@ package eu.kanade.domain.library.service +import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.library.model.LibrarySort import eu.kanade.domain.manga.model.Manga @@ -13,6 +14,7 @@ import eu.kanade.tachiyomi.widget.ExtendedNavigationView class LibraryPreferences( private val preferenceStore: PreferenceStore, ) { + fun bottomNavStyle() = preferenceStore.getInt("bottom_nav_style", 0) fun libraryDisplayMode() = preferenceStore.getObject("pref_display_mode_library", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize) @@ -60,41 +62,54 @@ class LibraryPreferences( fun showUpdatesNavBadge() = preferenceStore.getBoolean("library_update_show_tab_badge", false) fun unreadUpdatesCount() = preferenceStore.getInt("library_unread_updates_count", 0) + fun unseenUpdatesCount() = preferenceStore.getInt("library_unseen_updates_count", 0) // endregion // region Category fun defaultCategory() = preferenceStore.getInt("default_category", -1) + fun defaultAnimeCategory() = preferenceStore.getInt("default_anime_category", -1) fun lastUsedCategory() = preferenceStore.getInt("last_used_category", 0) + fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0) fun categoryTabs() = preferenceStore.getBoolean("display_category_tabs", true) + fun animeCategoryTabs() = preferenceStore.getBoolean("display_anime_category_tabs", true) fun categoryNumberOfItems() = preferenceStore.getBoolean("display_number_of_items", false) + fun animeCategoryNumberOfItems() = preferenceStore.getBoolean("display_number_of_items_anime", false) fun categorizedDisplaySettings() = preferenceStore.getBoolean("categorized_display", false) fun libraryUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet()) + fun animelibUpdateCategories() = preferenceStore.getStringSet("animelib_update_categories", emptySet()) fun libraryUpdateCategoriesExclude() = preferenceStore.getStringSet("library_update_categories_exclude", emptySet()) + fun animelibUpdateCategoriesExclude() = preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet()) // endregion // region Chapter fun filterChapterByRead() = preferenceStore.getLong("default_chapter_filter_by_read", Manga.SHOW_ALL) + fun filterEpisodeBySeen() = preferenceStore.getLong("default_episode_filter_by_seen", Anime.SHOW_ALL) fun filterChapterByDownloaded() = preferenceStore.getLong("default_chapter_filter_by_downloaded", Manga.SHOW_ALL) + fun filterEpisodeByDownloaded() = preferenceStore.getLong("default_episode_filter_by_downloaded", Anime.SHOW_ALL) fun filterChapterByBookmarked() = preferenceStore.getLong("default_chapter_filter_by_bookmarked", Manga.SHOW_ALL) + fun filterEpisodeByBookmarked() = preferenceStore.getLong("default_episode_filter_by_bookmarked", Anime.SHOW_ALL) // and upload date fun sortChapterBySourceOrNumber() = preferenceStore.getLong("default_chapter_sort_by_source_or_number", Manga.CHAPTER_SORTING_SOURCE) + fun sortEpisodeBySourceOrNumber() = preferenceStore.getLong("default_episode_sort_by_source_or_number", Anime.EPISODE_SORTING_SOURCE) fun displayChapterByNameOrNumber() = preferenceStore.getLong("default_chapter_display_by_name_or_number", Manga.CHAPTER_DISPLAY_NAME) + fun displayEpisodeByNameOrNumber() = preferenceStore.getLong("default_chapter_display_by_name_or_number", Anime.EPISODE_DISPLAY_NAME) fun sortChapterByAscendingOrDescending() = preferenceStore.getLong("default_chapter_sort_by_ascending_or_descending", Manga.CHAPTER_SORT_DESC) + fun sortEpisodeByAscendingOrDescending() = preferenceStore.getLong("default_chapter_sort_by_ascending_or_descending", Anime.EPISODE_SORT_DESC) fun setChapterSettingsDefault(manga: Manga) { filterChapterByRead().set(manga.unreadFilterRaw) @@ -105,6 +120,15 @@ class LibraryPreferences( sortChapterByAscendingOrDescending().set(if (manga.sortDescending()) Manga.CHAPTER_SORT_DESC else Manga.CHAPTER_SORT_ASC) } + fun setEpisodeSettingsDefault(anime: Anime) { + filterEpisodeBySeen().set(anime.unseenFilterRaw) + filterEpisodeByDownloaded().set(anime.downloadedFilterRaw) + filterEpisodeByBookmarked().set(anime.bookmarkedFilterRaw) + sortEpisodeBySourceOrNumber().set(anime.sorting) + displayEpisodeByNameOrNumber().set(anime.displayMode) + sortEpisodeByAscendingOrDescending().set(if (anime.sortDescending()) Anime.EPISODE_SORT_DESC else Anime.EPISODE_SORT_ASC) + } + fun autoClearChapterCache() = preferenceStore.getBoolean("auto_clear_chapter_cache", false) // endregion diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 2fc27ab0d..1c3aa1b40 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -16,12 +16,18 @@ class SourcePreferences( fun disabledSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet()) + fun disabledAnimeSources() = preferenceStore.getStringSet("hidden_anime_catalogues", emptySet()) + fun pinnedSources() = preferenceStore.getStringSet("pinned_catalogues", emptySet()) + fun pinnedAnimeSources() = preferenceStore.getStringSet("pinned_anime_catalogues", emptySet()) + fun duplicatePinnedSources() = preferenceStore.getBoolean("duplicate_pinned_sources", false) fun lastUsedSource() = preferenceStore.getLong("last_catalogue_source", -1) + fun lastUsedAnimeSource() = preferenceStore.getLong("last_anime_catalogue_source", -1) + fun showNsfwSource() = preferenceStore.getBoolean("show_nsfw_source", true) fun migrationSortingMode() = preferenceStore.getEnum("pref_migration_sorting", SetMigrateSorting.Mode.ALPHABETICAL) @@ -30,7 +36,11 @@ class SourcePreferences( fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) + fun animeextensionUpdatesCount() = preferenceStore.getInt("animeext_updates_count", 0) + fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet()) fun searchPinnedSourcesOnly() = preferenceStore.getBoolean("search_pinned_sources_only", false) + + fun searchAnimePinnedSourcesOnly() = preferenceStore.getBoolean("search_pinned_anime_sources_only", false) } diff --git a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt index 558538b85..585e80165 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt @@ -2,17 +2,12 @@ package eu.kanade.presentation.anime import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.rememberSplineBasedDecay -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.rememberScrollableState -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -24,7 +19,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -36,72 +30,54 @@ import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.rememberTopAppBarScrollState -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState -import eu.kanade.domain.anime.model.Anime.Companion.EPISODE_DISPLAY_NUMBER import eu.kanade.domain.episode.model.Episode -import eu.kanade.presentation.anime.components.AnimeBottomActionMenu +import eu.kanade.presentation.anime.components.AnimeActionRow import eu.kanade.presentation.anime.components.AnimeEpisodeListItem -import eu.kanade.presentation.anime.components.AnimeInfoHeader -import eu.kanade.presentation.anime.components.AnimeSmallAppBar -import eu.kanade.presentation.anime.components.AnimeTopAppBar +import eu.kanade.presentation.anime.components.AnimeInfoBox import eu.kanade.presentation.anime.components.EpisodeHeader +import eu.kanade.presentation.anime.components.ExpandableAnimeDescription +import eu.kanade.presentation.components.AnimeBottomActionMenu +import eu.kanade.presentation.components.EpisodeDownloadAction import eu.kanade.presentation.components.ExtendedFloatingActionButton +import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.Scaffold -import eu.kanade.presentation.components.SwipeRefreshIndicator +import eu.kanade.presentation.components.SwipeRefresh +import eu.kanade.presentation.components.TwoPanelBox import eu.kanade.presentation.components.VerticalFastScroller import eu.kanade.presentation.manga.DownloadAction -import eu.kanade.presentation.manga.EpisodeDownloadAction -import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior +import eu.kanade.presentation.manga.MangaScreenItem +import eu.kanade.presentation.manga.components.MangaToolbar import eu.kanade.presentation.util.isScrolledToEnd import eu.kanade.presentation.util.isScrollingUp -import eu.kanade.presentation.util.plus import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.animesource.getNameForAnimeInfo -import eu.kanade.tachiyomi.data.download.model.AnimeDownload -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload import eu.kanade.tachiyomi.ui.anime.AnimeScreenState import eu.kanade.tachiyomi.ui.anime.EpisodeItem -import eu.kanade.tachiyomi.util.lang.toRelativeString -import kotlinx.coroutines.runBlocking +import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Date -import java.util.concurrent.TimeUnit - -private val episodeDecimalFormat = DecimalFormat( - "#.###", - DecimalFormatSymbols() - .apply { decimalSeparator = '.' }, -) @Composable fun AnimeScreen( state: AnimeScreenState.Success, snackbarHostState: SnackbarHostState, - windowWidthSizeClass: WindowWidthSizeClass, + isTabletUi: Boolean, onBackClicked: () -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit, onDownloadEpisode: ((List, EpisodeDownloadAction) -> Unit)?, @@ -130,8 +106,12 @@ fun AnimeScreen( onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, + // Episode selection + onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, ) { - if (windowWidthSizeClass == WindowWidthSizeClass.Compact) { + if (!isTabletUi) { AnimeScreenSmallImpl( state = state, snackbarHostState = snackbarHostState, @@ -142,9 +122,9 @@ fun AnimeScreen( onWebViewClicked = onWebViewClicked, onTrackingClicked = onTrackingClicked, onTagClicked = onTagClicked, - onFilterButtonClicked = onFilterButtonClicked, + onFilterClicked = onFilterButtonClicked, onRefresh = onRefresh, - onContinueReading = onContinueWatching, + onContinueWatching = onContinueWatching, onSearch = onSearch, onCoverClicked = onCoverClicked, onShareClicked = onShareClicked, @@ -156,11 +136,13 @@ fun AnimeScreen( onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMultiDeleteClicked = onMultiDeleteClicked, + onEpisodeSelected = onEpisodeSelected, + onAllEpisodeSelected = onAllEpisodeSelected, + onInvertSelection = onInvertSelection, ) } else { AnimeScreenLargeImpl( state = state, - windowWidthSizeClass = windowWidthSizeClass, snackbarHostState = snackbarHostState, onBackClicked = onBackClicked, onEpisodeClicked = onEpisodeClicked, @@ -171,7 +153,7 @@ fun AnimeScreen( onTagClicked = onTagClicked, onFilterButtonClicked = onFilterButtonClicked, onRefresh = onRefresh, - onContinueReading = onContinueWatching, + onContinueWatching = onContinueWatching, onSearch = onSearch, onCoverClicked = onCoverClicked, onShareClicked = onShareClicked, @@ -183,6 +165,9 @@ fun AnimeScreen( onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMultiDeleteClicked = onMultiDeleteClicked, + onEpisodeSelected = onEpisodeSelected, + onAllEpisodeSelected = onAllEpisodeSelected, + onInvertSelection = onInvertSelection, ) } } @@ -198,9 +183,9 @@ private fun AnimeScreenSmallImpl( onWebViewClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?, onTagClicked: (String) -> Unit, - onFilterButtonClicked: () -> Unit, + onFilterClicked: () -> Unit, onRefresh: () -> Unit, - onContinueReading: () -> Unit, + onContinueWatching: () -> Unit, onSearch: (query: String, global: Boolean) -> Unit, // For cover dialog @@ -215,175 +200,185 @@ private fun AnimeScreenSmallImpl( // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - onMultiMarkAsSeenClicked: (List, markAsRead: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, + // Episode selection + onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, ) { - val layoutDirection = LocalLayoutDirection.current - val decayAnimationSpec = rememberSplineBasedDecay() - val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec) val episodeListState = rememberLazyListState() - SideEffect { - if (episodeListState.firstVisibleItemIndex > 0 || episodeListState.firstVisibleItemScrollOffset > 0) { - // Should go here after a configuration change - // Safe to say that the app bar is fully scrolled - scrollBehavior.state.offset = scrollBehavior.state.offsetLimit + + val episodes = remember(state) { state.processedEpisodes.toList() } + + val internalOnBackPressed = { + if (episodes.any { it.selected }) { + onAllEpisodeSelected(false) + } else { + onBackClicked() } } - - val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() - val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) } - SwipeRefresh( - state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingEpisode), - onRefresh = onRefresh, - indicatorPadding = PaddingValues( - start = insetPadding.calculateStartPadding(layoutDirection), - top = with(LocalDensity.current) { topBarHeight.toDp() }, - end = insetPadding.calculateEndPadding(layoutDirection), - ), - indicator = { s, trigger -> - SwipeRefreshIndicator( - state = s, - refreshTriggerDistance = trigger, + BackHandler(onBack = internalOnBackPressed) + Scaffold( + modifier = Modifier + .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues()), + topBar = { + val firstVisibleItemIndex by remember { + derivedStateOf { episodeListState.firstVisibleItemIndex } + } + val firstVisibleItemScrollOffset by remember { + derivedStateOf { episodeListState.firstVisibleItemScrollOffset } + } + val animatedTitleAlpha by animateFloatAsState( + if (firstVisibleItemIndex > 0) 1f else 0f, + ) + val animatedBgAlpha by animateFloatAsState( + if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, + ) + MangaToolbar( + title = state.anime.title, + titleAlphaProvider = { animatedTitleAlpha }, + backgroundAlphaProvider = { animatedBgAlpha }, + hasFilters = state.anime.episodesFiltered(), + incognitoMode = state.isIncognitoMode, + downloadedOnlyMode = state.isDownloadedOnlyMode, + onBackClicked = internalOnBackPressed, + onClickFilter = onFilterClicked, + onClickShare = onShareClicked, + onClickDownload = onDownloadActionClicked, + onClickEditCategory = onEditCategoryClicked, + onClickMigrate = onMigrateClicked, + actionModeCounter = episodes.count { it.selected }, + onSelectAll = { onAllEpisodeSelected(true) }, + onInvertSelection = { onInvertSelection() }, ) }, - ) { - val episodes = remember(state) { state.processedEpisodes.toList() } - val selected = remember(episodes) { emptyList().toMutableStateList() } - val selectedPositions = remember(episodes) { arrayOf(-1, -1) } // first and last selected index in list - - val internalOnBackPressed = { - if (selected.isNotEmpty()) { - selected.clear() - } else { - onBackClicked() - } - } - BackHandler(onBack = internalOnBackPressed) - - Scaffold( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .padding(insetPadding), - topBar = { - AnimeTopAppBar( + bottomBar = { + SharedAnimeBottomActionMenu( + selected = episodes.filter { it.selected }, + onEpisodeClicked = onEpisodeClicked, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onDownloadEpisode = onDownloadEpisode, + onMultiDeleteClicked = onMultiDeleteClicked, + fillFraction = 1f, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + AnimatedVisibility( + visible = episodes.any { !it.episode.seen } && episodes.none { it.selected }, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExtendedFloatingActionButton( + text = { + val id = if (episodes.any { it.episode.seen }) { + R.string.action_resume + } else { + R.string.action_start + } + Text(text = stringResource(id)) + }, + icon = { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + ) + }, + onClick = onContinueWatching, + expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(), modifier = Modifier - .scrollable( - state = rememberScrollableState { - var consumed = runBlocking { episodeListState.scrollBy(-it) } * -1 - if (consumed == 0f) { - // Pass scroll to app bar if we're on the top of the list - val newOffset = - (scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f) - consumed = newOffset - scrollBehavior.state.offset - scrollBehavior.state.offset = newOffset - } - consumed - }, - orientation = Orientation.Vertical, - interactionSource = episodeListState.interactionSource as MutableInteractionSource, + .padding( + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom) + .asPaddingValues(), ), - title = state.anime.title, - author = state.anime.author, - artist = state.anime.artist, - description = state.anime.description, - tagsProvider = { state.anime.genre }, - coverDataProvider = { state.anime }, - sourceName = remember { state.source.getNameForAnimeInfo() }, - isStubSource = remember { state.source is SourceManager.StubSource }, - favorite = state.anime.favorite, - status = state.anime.status, - trackingCount = state.trackingCount, - episodeCount = episodes.size, - episodeFiltered = state.anime.episodesFiltered(), - incognitoMode = state.isIncognitoMode, - downloadedOnlyMode = state.isDownloadedOnlyMode, - fromSource = state.isFromSource, - onBackClicked = internalOnBackPressed, - onCoverClick = onCoverClicked, - onTagClicked = onTagClicked, - onAddToLibraryClicked = onAddToLibraryClicked, - onWebViewClicked = onWebViewClicked, - onTrackingClicked = onTrackingClicked, - onFilterButtonClicked = onFilterButtonClicked, - onShareClicked = onShareClicked, - onDownloadClicked = onDownloadActionClicked, - onEditCategoryClicked = onEditCategoryClicked, - onMigrateClicked = onMigrateClicked, - changeAnimeSkipIntro = changeAnimeSkipIntro, - doGlobalSearch = onSearch, - scrollBehavior = scrollBehavior, - actionModeCounter = selected.size, - onSelectAll = { - selected.clear() - selected.addAll(episodes) - }, - onInvertSelection = { - val toSelect = episodes - selected - selected.clear() - selected.addAll(toSelect) - }, - onSmallAppBarHeightChanged = onTopBarHeightChanged, ) - }, - bottomBar = { - SharedAnimeBottomActionMenu( - selected = selected, - onEpisodeClicked = onEpisodeClicked, - onMultiBookmarkClicked = onMultiBookmarkClicked, - onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, - onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, - onDownloadEpisode = onDownloadEpisode, - onMultiDeleteClicked = onMultiDeleteClicked, - fillFraction = 1f, - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - floatingActionButton = { - AnimatedVisibility( - visible = episodes.any { !it.episode.seen } && selected.isEmpty(), - enter = fadeIn(), - exit = fadeOut(), - ) { - ExtendedFloatingActionButton( - text = { - val id = if (episodes.any { it.episode.seen }) { - R.string.action_resume - } else { - R.string.action_start - } - Text(text = stringResource(id)) - }, - icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) }, - onClick = onContinueReading, - expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(), - modifier = Modifier - .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()), - ) - } - }, - ) { contentPadding -> - val withNavBarContentPadding = contentPadding + - WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + } + }, + ) { contentPadding -> + val topPadding = contentPadding.calculateTopPadding() + + SwipeRefresh( + refreshing = state.isRefreshingData, + onRefresh = onRefresh, + enabled = episodes.none { it.selected }, + indicatorPadding = contentPadding, + ) { VerticalFastScroller( listState = episodeListState, - thumbAllowed = { scrollBehavior.state.offset == scrollBehavior.state.offsetLimit }, - topContentPadding = withNavBarContentPadding.calculateTopPadding(), - endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current), + topContentPadding = topPadding, ) { LazyColumn( modifier = Modifier.fillMaxHeight(), state = episodeListState, - contentPadding = withNavBarContentPadding, + contentPadding = PaddingValues( + bottom = contentPadding.calculateBottomPadding(), + ), ) { + item( + key = MangaScreenItem.INFO_BOX, + contentType = MangaScreenItem.INFO_BOX, + ) { + AnimeInfoBox( + isTabletUi = false, + appBarPadding = topPadding, + title = state.anime.title, + author = state.anime.author, + artist = state.anime.artist, + sourceName = remember { state.source.getNameForAnimeInfo() }, + isStubSource = remember { state.source is AnimeSourceManager.StubAnimeSource }, + coverDataProvider = { state.anime }, + status = state.anime.status, + onCoverClick = onCoverClicked, + doSearch = onSearch, + ) + } + + item( + key = MangaScreenItem.ACTION_ROW, + contentType = MangaScreenItem.ACTION_ROW, + ) { + AnimeActionRow( + favorite = state.anime.favorite, + trackingCount = state.trackingCount, + onAddToLibraryClicked = onAddToLibraryClicked, + onWebViewClicked = onWebViewClicked, + onTrackingClicked = onTrackingClicked, + onEditCategory = onEditCategoryClicked, + ) + } + + item( + key = MangaScreenItem.DESCRIPTION_WITH_TAG, + contentType = MangaScreenItem.DESCRIPTION_WITH_TAG, + ) { + ExpandableAnimeDescription( + defaultExpandState = state.isFromSource, + description = state.anime.description, + tagsProvider = { state.anime.genre }, + onTagClicked = onTagClicked, + ) + } + + item( + key = MangaScreenItem.CHAPTER_HEADER, + contentType = MangaScreenItem.CHAPTER_HEADER, + ) { + EpisodeHeader( + episodeCount = episodes.size, + onClick = onFilterClicked, + ) + } + sharedEpisodeItems( episodes = episodes, - state = state, - selected = selected, - selectedPositions = selectedPositions, onEpisodeClicked = onEpisodeClicked, onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, ) } } @@ -394,7 +389,6 @@ private fun AnimeScreenSmallImpl( @Composable fun AnimeScreenLargeImpl( state: AnimeScreenState.Success, - windowWidthSizeClass: WindowWidthSizeClass, snackbarHostState: SnackbarHostState, onBackClicked: () -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit, @@ -405,7 +399,7 @@ fun AnimeScreenLargeImpl( onTagClicked: (String) -> Unit, onFilterButtonClicked: () -> Unit, onRefresh: () -> Unit, - onContinueReading: () -> Unit, + onContinueWatching: () -> Unit, onSearch: (query: String, global: Boolean) -> Unit, // For cover dialog @@ -420,40 +414,37 @@ fun AnimeScreenLargeImpl( // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - onMultiMarkAsSeenClicked: (List, markAsRead: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, + // Episode selection + onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, ) { val layoutDirection = LocalLayoutDirection.current val density = LocalDensity.current + val episodes = remember(state) { state.processedEpisodes.toList() } + val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() - val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) } + var topBarHeight by remember { mutableStateOf(0) } SwipeRefresh( - state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingEpisode), + refreshing = state.isRefreshingData, onRefresh = onRefresh, + enabled = episodes.none { it.selected }, indicatorPadding = PaddingValues( start = insetPadding.calculateStartPadding(layoutDirection), top = with(density) { topBarHeight.toDp() }, end = insetPadding.calculateEndPadding(layoutDirection), ), - clipIndicatorToPadding = true, - indicator = { s, trigger -> - SwipeRefreshIndicator( - state = s, - refreshTriggerDistance = trigger, - ) - }, ) { val episodeListState = rememberLazyListState() - val episodes = remember(state) { state.processedEpisodes.toList() } - val selected = remember(episodes) { emptyList().toMutableStateList() } - val selectedPositions = remember(episodes) { arrayOf(-1, -1) } // first and last selected index in list val internalOnBackPressed = { - if (selected.isNotEmpty()) { - selected.clear() + if (episodes.any { it.selected }) { + onAllEpisodeSelected(false) } else { onBackClicked() } @@ -463,29 +454,23 @@ fun AnimeScreenLargeImpl( Scaffold( modifier = Modifier.padding(insetPadding), topBar = { - AnimeSmallAppBar( - modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) }, + MangaToolbar( + modifier = Modifier.onSizeChanged { topBarHeight = (it.height) }, title = state.anime.title, - titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f }, + titleAlphaProvider = { if (episodes.any { it.selected }) 1f else 0f }, backgroundAlphaProvider = { 1f }, + hasFilters = state.anime.episodesFiltered(), incognitoMode = state.isIncognitoMode, downloadedOnlyMode = state.isDownloadedOnlyMode, onBackClicked = internalOnBackPressed, - onShareClicked = onShareClicked, - onDownloadClicked = onDownloadActionClicked, - onEditCategoryClicked = onEditCategoryClicked, - onMigrateClicked = onMigrateClicked, - changeAnimeSkipIntro = changeAnimeSkipIntro, - actionModeCounter = selected.size, - onSelectAll = { - selected.clear() - selected.addAll(episodes) - }, - onInvertSelection = { - val toSelect = episodes - selected - selected.clear() - selected.addAll(toSelect) - }, + onClickFilter = onFilterButtonClicked, + onClickShare = onShareClicked, + onClickDownload = onDownloadActionClicked, + onClickEditCategory = onEditCategoryClicked, + onClickMigrate = onMigrateClicked, + actionModeCounter = episodes.count { it.selected }, + onSelectAll = { onAllEpisodeSelected(true) }, + onInvertSelection = { onInvertSelection() }, ) }, bottomBar = { @@ -494,7 +479,7 @@ fun AnimeScreenLargeImpl( contentAlignment = Alignment.BottomEnd, ) { SharedAnimeBottomActionMenu( - selected = selected, + selected = episodes.filter { it.selected }, onEpisodeClicked = onEpisodeClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, @@ -508,7 +493,7 @@ fun AnimeScreenLargeImpl( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { AnimatedVisibility( - visible = episodes.any { !it.episode.seen } && selected.isEmpty(), + visible = episodes.any { !it.episode.seen } && episodes.none { it.selected }, enter = fadeIn(), exit = fadeOut(), ) { @@ -521,8 +506,8 @@ fun AnimeScreenLargeImpl( } Text(text = stringResource(id)) }, - icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) }, - onClick = onContinueReading, + icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, + onClick = onContinueWatching, expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(), modifier = Modifier .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()), @@ -530,75 +515,82 @@ fun AnimeScreenLargeImpl( } }, ) { contentPadding -> - Row { - val withNavBarContentPadding = contentPadding + - WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() - AnimeInfoHeader( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(bottom = withNavBarContentPadding.calculateBottomPadding()), - windowWidthSizeClass = WindowWidthSizeClass.Expanded, - appBarPadding = contentPadding.calculateTopPadding(), - title = state.anime.title, - author = state.anime.author, - artist = state.anime.artist, - description = state.anime.description, - tagsProvider = { state.anime.genre }, - sourceName = remember { state.source.getNameForAnimeInfo() }, - isStubSource = remember { state.source is AnimeSourceManager.StubSource }, - coverDataProvider = { state.anime }, - favorite = state.anime.favorite, - status = state.anime.status, - trackingCount = state.trackingCount, - fromSource = state.isFromSource, - onAddToLibraryClicked = onAddToLibraryClicked, - onWebViewClicked = onWebViewClicked, - onTrackingClicked = onTrackingClicked, - onTagClicked = onTagClicked, - onEditCategory = onEditCategoryClicked, - onCoverClick = onCoverClicked, - doSearch = onSearch, - ) - - val episodesWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f - VerticalFastScroller( - listState = episodeListState, - modifier = Modifier.weight(episodesWeight), - topContentPadding = withNavBarContentPadding.calculateTopPadding(), - endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection), - ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - state = episodeListState, - contentPadding = withNavBarContentPadding, + TwoPanelBox( + startContent = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()), ) { - item(contentType = "header") { - EpisodeHeader( - episodeCount = episodes.size, - isEpisodeFiltered = state.anime.episodesFiltered(), - onFilterButtonClicked = onFilterButtonClicked, - ) - } - - sharedEpisodeItems( - episodes = episodes, - state = state, - selected = selected, - selectedPositions = selectedPositions, - onEpisodeClicked = onEpisodeClicked, - onDownloadEpisode = onDownloadEpisode, + AnimeInfoBox( + isTabletUi = true, + appBarPadding = contentPadding.calculateTopPadding(), + title = state.anime.title, + author = state.anime.author, + artist = state.anime.artist, + sourceName = remember { state.source.getNameForAnimeInfo() }, + isStubSource = remember { state.source is AnimeSourceManager.StubAnimeSource }, + coverDataProvider = { state.anime }, + status = state.anime.status, + onCoverClick = onCoverClicked, + doSearch = onSearch, + ) + AnimeActionRow( + favorite = state.anime.favorite, + trackingCount = state.trackingCount, + onAddToLibraryClicked = onAddToLibraryClicked, + onWebViewClicked = onWebViewClicked, + onTrackingClicked = onTrackingClicked, + onEditCategory = onEditCategoryClicked, + ) + ExpandableAnimeDescription( + defaultExpandState = true, + description = state.anime.description, + tagsProvider = { state.anime.genre }, + onTagClicked = onTagClicked, ) } - } - } + }, + endContent = { + VerticalFastScroller( + listState = episodeListState, + topContentPadding = contentPadding.calculateTopPadding(), + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + state = episodeListState, + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ), + ) { + item( + key = MangaScreenItem.CHAPTER_HEADER, + contentType = MangaScreenItem.CHAPTER_HEADER, + ) { + EpisodeHeader( + episodeCount = episodes.size, + onClick = onFilterButtonClicked, + ) + } + + sharedEpisodeItems( + episodes = episodes, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, + ) + } + } + }, + ) } } } @Composable private fun SharedAnimeBottomActionMenu( - selected: SnapshotStateList, + selected: List, + modifier: Modifier = Modifier, onEpisodeClicked: (Episode, Boolean) -> Unit, onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, @@ -607,225 +599,97 @@ private fun SharedAnimeBottomActionMenu( onMultiDeleteClicked: (List) -> Unit, fillFraction: Float, ) { - val preferences: PreferencesHelper = Injekt.get() + val preferences: PlayerPreferences = Injekt.get() AnimeBottomActionMenu( visible = selected.isNotEmpty(), - modifier = Modifier.fillMaxWidth(fillFraction), + modifier = modifier.fillMaxWidth(fillFraction), onBookmarkClicked = { onMultiBookmarkClicked.invoke(selected.map { it.episode }, true) - selected.clear() }.takeIf { selected.any { !it.episode.bookmark } }, onRemoveBookmarkClicked = { onMultiBookmarkClicked.invoke(selected.map { it.episode }, false) - selected.clear() }.takeIf { selected.all { it.episode.bookmark } }, onMarkAsSeenClicked = { onMultiMarkAsSeenClicked(selected.map { it.episode }, true) - selected.clear() }.takeIf { selected.any { !it.episode.seen } }, onMarkAsUnseenClicked = { onMultiMarkAsSeenClicked(selected.map { it.episode }, false) - selected.clear() - }.takeIf { selected.any { it.episode.seen } }, + }.takeIf { selected.any { it.episode.seen || it.episode.lastSecondSeen > 0L } }, onMarkPreviousAsSeenClicked = { onMarkPreviousAsSeenClicked(selected[0].episode) - selected.clear() }.takeIf { selected.size == 1 }, onDownloadClicked = { onDownloadEpisode!!(selected.toList(), EpisodeDownloadAction.START) - selected.clear() }.takeIf { onDownloadEpisode != null && selected.any { it.downloadState != AnimeDownload.State.DOWNLOADED } }, onDeleteClicked = { onMultiDeleteClicked(selected.map { it.episode }) - selected.clear() }.takeIf { onDownloadEpisode != null && selected.any { it.downloadState == AnimeDownload.State.DOWNLOADED } }, onExternalClicked = { onEpisodeClicked(selected.map { it.episode }.first(), true) - selected.clear() - }.takeIf { !preferences.alwaysUseExternalPlayer() && selected.size == 1 }, + }.takeIf { !preferences.alwaysUseExternalPlayer().get() && selected.size == 1 }, onInternalClicked = { onEpisodeClicked(selected.map { it.episode }.first(), true) - selected.clear() - }.takeIf { preferences.alwaysUseExternalPlayer() && selected.size == 1 }, + }.takeIf { preferences.alwaysUseExternalPlayer().get() && selected.size == 1 }, ) } private fun LazyListScope.sharedEpisodeItems( episodes: List, - state: AnimeScreenState.Success, - selected: SnapshotStateList, - selectedPositions: Array, onEpisodeClicked: (Episode, Boolean) -> Unit, onDownloadEpisode: ((List, EpisodeDownloadAction) -> Unit)?, + onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit, ) { - items(items = episodes) { episodeItem -> - val context = LocalContext.current + items( + items = episodes, + key = { "episode-${it.episode.id}" }, + contentType = { MangaScreenItem.CHAPTER }, + ) { episodeItem -> val haptic = LocalHapticFeedback.current - val (episode, downloadState, downloadProgress) = episodeItem - val episodeTitle = if (state.anime.displayMode == EPISODE_DISPLAY_NUMBER) { - stringResource( - id = R.string.display_mode_episode, - episodeDecimalFormat.format(episode.episodeNumber.toDouble()), - ) - } else { - episode.name - } - val date = remember(episode.dateUpload) { - episode.dateUpload - .takeIf { it > 0 } - ?.let { - Date(it).toRelativeString( - context, - state.dateRelativeTime, - state.dateFormat, - ) - } - } - val lastSecondSeen = remember(episode.lastSecondSeen, episode.seen) { - episode.lastSecondSeen.takeIf { !episode.seen && it > 0 } - } - val totalSeconds = remember(episode.totalSeconds) { - episode.totalSeconds.takeIf { !episode.seen && it > 0 } - } - val scanlator = remember(episode.scanlator) { episode.scanlator.takeIf { !it.isNullOrBlank() } } - AnimeEpisodeListItem( - title = episodeTitle, - date = date, - watchProgress = lastSecondSeen?.let { - if (totalSeconds != null) { - stringResource( - id = R.string.episode_progress, - formatProgress(lastSecondSeen), - formatProgress(totalSeconds), - ) - } else { - stringResource( - id = R.string.episode_progress_no_total, - formatProgress(lastSecondSeen), - ) - } - }, - scanlator = scanlator, - seen = episode.seen, - bookmark = episode.bookmark, - selected = selected.contains(episodeItem), - downloadState = downloadState, - downloadProgress = downloadProgress, + title = episodeItem.episodeTitleString, + date = episodeItem.dateUploadString, + watchProgress = episodeItem.seenProgressString, + scanlator = episodeItem.episode.scanlator.takeIf { !it.isNullOrBlank() }, + seen = episodeItem.episode.seen, + bookmark = episodeItem.episode.bookmark, + selected = episodeItem.selected, + downloadStateProvider = { episodeItem.downloadState }, + downloadProgressProvider = { episodeItem.downloadProgress }, onLongClick = { - val dispatched = onEpisodeItemLongClick( - episodeItem = episodeItem, - selected = selected, - episodes = episodes, - selectedPositions = selectedPositions, - ) - if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onEpisodeSelected(episodeItem, !episodeItem.selected, true, true) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, onClick = { onEpisodeItemClick( episodeItem = episodeItem, - selected = selected, episodes = episodes, - selectedPositions = selectedPositions, + onToggleSelection = { onEpisodeSelected(episodeItem, !episodeItem.selected, true, false) }, onEpisodeClicked = onEpisodeClicked, ) }, onDownloadClick = if (onDownloadEpisode != null) { { onDownloadEpisode(listOf(episodeItem), it) } - } else null, + } else { + null + }, ) } } -private fun onEpisodeItemLongClick( - episodeItem: EpisodeItem, - selected: MutableList, - episodes: List, - selectedPositions: Array, -): Boolean { - if (!selected.contains(episodeItem)) { - val selectedIndex = episodes.indexOf(episodeItem) - if (selected.isEmpty()) { - selected.add(episodeItem) - selectedPositions[0] = selectedIndex - selectedPositions[1] = selectedIndex - return true - } - - // Try to select the items in-between when possible - val range: IntRange - if (selectedIndex < selectedPositions[0]) { - range = selectedIndex until selectedPositions[0] - selectedPositions[0] = selectedIndex - } else if (selectedIndex > selectedPositions[1]) { - range = (selectedPositions[1] + 1)..selectedIndex - selectedPositions[1] = selectedIndex - } else { - // Just select itself - range = selectedIndex..selectedIndex - } - - range.forEach { - val toAdd = episodes[it] - if (!selected.contains(toAdd)) { - selected.add(toAdd) - } - } - return true - } - return false -} - private fun onEpisodeItemClick( episodeItem: EpisodeItem, - selected: MutableList, episodes: List, - selectedPositions: Array, + onToggleSelection: (Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit, ) { - val selectedIndex = episodes.indexOf(episodeItem) when { - selected.contains(episodeItem) -> { - val removedIndex = episodes.indexOf(episodeItem) - selected.remove(episodeItem) - - if (removedIndex == selectedPositions[0]) { - selectedPositions[0] = episodes.indexOfFirst { selected.contains(it) } - } else if (removedIndex == selectedPositions[1]) { - selectedPositions[1] = episodes.indexOfLast { selected.contains(it) } - } - } - selected.isNotEmpty() -> { - if (selectedIndex < selectedPositions[0]) { - selectedPositions[0] = selectedIndex - } else if (selectedIndex > selectedPositions[1]) { - selectedPositions[1] = selectedIndex - } - selected.add(episodeItem) - } + episodeItem.selected -> onToggleSelection(false) + episodes.any { it.selected } -> onToggleSelection(true) else -> onEpisodeClicked(episodeItem.episode, false) } } - -private fun formatProgress(milliseconds: Long): String { - return if (milliseconds > 3600000L) String.format( - "%d:%02d:%02d", - TimeUnit.MILLISECONDS.toHours(milliseconds), - TimeUnit.MILLISECONDS.toMinutes(milliseconds) - - TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(milliseconds)), - TimeUnit.MILLISECONDS.toSeconds(milliseconds) - - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(milliseconds)), - ) else { - String.format( - "%d:%02d", - TimeUnit.MILLISECONDS.toMinutes(milliseconds), - TimeUnit.MILLISECONDS.toSeconds(milliseconds) - - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(milliseconds)), - ) - } -} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt index 3d72362fd..7e8a311cc 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Share @@ -24,11 +24,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.updatePadding @@ -63,7 +66,7 @@ fun AnimeCoverDialog( ) { IconButton(onClick = onDismissRequest) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Outlined.Close, contentDescription = stringResource(R.string.action_close), ) } @@ -82,9 +85,15 @@ fun AnimeCoverDialog( } if (onEditClick != null) { Box { - val (expanded, onExpand) = remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } IconButton( - onClick = { if (isCustomCover) onExpand(true) else onEditClick(EditCoverAction.EDIT) }, + onClick = { + if (isCustomCover) { + expanded = true + } else { + onEditClick(EditCoverAction.EDIT) + } + }, ) { Icon( imageVector = Icons.Outlined.Edit, @@ -93,20 +102,21 @@ fun AnimeCoverDialog( } DropdownMenu( expanded = expanded, - onDismissRequest = { onExpand(false) }, + onDismissRequest = { expanded = false }, + offset = DpOffset(8.dp, 0.dp), ) { DropdownMenuItem( text = { Text(text = stringResource(R.string.action_edit)) }, onClick = { onEditClick(EditCoverAction.EDIT) - onExpand(false) + expanded = false }, ) DropdownMenuItem( text = { Text(text = stringResource(R.string.action_delete)) }, onClick = { onEditClick(EditCoverAction.DELETE) - onExpand(false) + expanded = false }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeDialogs.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeDialogs.kt new file mode 100644 index 000000000..10667d46a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeDialogs.kt @@ -0,0 +1,39 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.tachiyomi.R + +@Composable +fun DeleteEpisodesDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirm() + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + title = { + Text(text = stringResource(R.string.are_you_sure)) + }, + text = { + Text(text = stringResource(R.string.confirm_delete_episodes)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt index 4fab57e0e..c46162379 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt @@ -29,11 +29,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import eu.kanade.presentation.components.EpisodeDownloadAction import eu.kanade.presentation.components.EpisodeDownloadIndicator -import eu.kanade.presentation.manga.EpisodeDownloadAction import eu.kanade.presentation.manga.components.DotSeparatorText +import eu.kanade.presentation.util.ReadItemAlpha import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload @Composable fun AnimeEpisodeListItem( @@ -45,8 +46,8 @@ fun AnimeEpisodeListItem( seen: Boolean, bookmark: Boolean, selected: Boolean, - downloadState: AnimeDownload.State, - downloadProgress: Int, + downloadStateProvider: () -> AnimeDownload.State, + downloadProgressProvider: () -> Int, onLongClick: () -> Unit, onClick: () -> Unit, onDownloadClick: ((EpisodeDownloadAction) -> Unit)?, @@ -66,13 +67,13 @@ fun AnimeEpisodeListItem( } else { MaterialTheme.colorScheme.onSurface } - val textAlpha = remember(seen) { if (seen) SeenItemAlpha else 1f } + val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f } Row(verticalAlignment = Alignment.CenterVertically) { var textHeight by remember { mutableStateOf(0) } if (bookmark) { Icon( - imageVector = Icons.Default.Bookmark, + imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.action_filter_bookmarked), modifier = Modifier .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), @@ -82,8 +83,8 @@ fun AnimeEpisodeListItem( } Text( text = title, - style = MaterialTheme.typography.bodyMedium - .copy(color = textColor), + color = textColor, + style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = { textHeight = it.size.height }, @@ -109,7 +110,7 @@ fun AnimeEpisodeListItem( text = watchProgress, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.alpha(SeenItemAlpha), + modifier = Modifier.alpha(ReadItemAlpha), ) if (scanlator != null) DotSeparatorText() } @@ -128,12 +129,10 @@ fun AnimeEpisodeListItem( if (onDownloadClick != null) { EpisodeDownloadIndicator( modifier = Modifier.padding(start = 4.dp), - downloadState = downloadState, - downloadProgress = downloadProgress, + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, onClick = onDownloadClick, ) } } } - -private const val SeenItemAlpha = .38f diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt index 3571092f3..9518296d6 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt @@ -21,19 +21,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.AttachMoney +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalMinimumTouchTargetEnforcement @@ -42,7 +43,6 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -62,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -76,7 +77,6 @@ import eu.kanade.presentation.components.MangaCover import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.manga.components.DotSeparatorText import eu.kanade.presentation.util.clickableNoIndication -import eu.kanade.presentation.util.quantityStringResource import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.model.SAnime @@ -86,179 +86,181 @@ import kotlin.math.roundToInt private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) @Composable -fun AnimeInfoHeader( +fun AnimeInfoBox( modifier: Modifier = Modifier, - windowWidthSizeClass: WindowWidthSizeClass, + isTabletUi: Boolean, appBarPadding: Dp, title: String, author: String?, artist: String?, - description: String?, - tagsProvider: () -> List?, sourceName: String, isStubSource: Boolean, coverDataProvider: () -> Anime, - favorite: Boolean, status: Long, - trackingCount: Int, - fromSource: Boolean, - onAddToLibraryClicked: () -> Unit, - onWebViewClicked: (() -> Unit)?, - onTrackingClicked: (() -> Unit)?, - onTagClicked: (String) -> Unit, - onEditCategory: (() -> Unit)?, onCoverClick: () -> Unit, doSearch: (query: String, global: Boolean) -> Unit, ) { - val context = LocalContext.current - Column(modifier = modifier) { - Box { - // Backdrop - val backdropGradientColors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.background, - ) - AsyncImage( - model = coverDataProvider(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .matchParentSize() - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient(colors = backdropGradientColors), - ) - } - .alpha(.2f), - ) - - // Anime & source info - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - if (windowWidthSizeClass == WindowWidthSizeClass.Compact) { - AnimeAndSourceTitlesSmall( - appBarPadding = appBarPadding, - coverDataProvider = coverDataProvider, - onCoverClick = onCoverClick, - title = title, - context = context, - doSearch = doSearch, - author = author, - artist = artist, - status = status, - sourceName = sourceName, - isStubSource = isStubSource, - ) - } else { - AnimeAndSourceTitlesLarge( - appBarPadding = appBarPadding, - coverDataProvider = coverDataProvider, - onCoverClick = onCoverClick, - title = title, - context = context, - doSearch = doSearch, - author = author, - artist = artist, - status = status, - sourceName = sourceName, - isStubSource = isStubSource, + Box(modifier = modifier) { + // Backdrop + val backdropGradientColors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.background, + ) + AsyncImage( + model = coverDataProvider(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(colors = backdropGradientColors), ) } + .alpha(.2f), + ) + + // Manga & source info + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + if (!isTabletUi) { + AnimeAndSourceTitlesSmall( + appBarPadding = appBarPadding, + coverDataProvider = coverDataProvider, + onCoverClick = onCoverClick, + title = title, + context = LocalContext.current, + doSearch = doSearch, + author = author, + artist = artist, + status = status, + sourceName = sourceName, + isStubSource = isStubSource, + ) + } else { + AnimeAndSourceTitlesLarge( + appBarPadding = appBarPadding, + coverDataProvider = coverDataProvider, + onCoverClick = onCoverClick, + title = title, + context = LocalContext.current, + doSearch = doSearch, + author = author, + artist = artist, + status = status, + sourceName = sourceName, + isStubSource = isStubSource, + ) } } + } +} - // Action buttons - Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { - val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) +@Composable +fun AnimeActionRow( + modifier: Modifier = Modifier, + favorite: Boolean, + trackingCount: Int, + onAddToLibraryClicked: () -> Unit, + onWebViewClicked: (() -> Unit)?, + onTrackingClicked: (() -> Unit)?, + onEditCategory: (() -> Unit)?, +) { + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { + val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + AnimeActionButton( + title = if (favorite) { + stringResource(R.string.in_library) + } else { + stringResource(R.string.add_to_library) + }, + icon = if (favorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = onAddToLibraryClicked, + onLongClick = onEditCategory, + ) + if (onTrackingClicked != null) { AnimeActionButton( - title = if (favorite) { - stringResource(R.string.in_library) + title = if (trackingCount == 0) { + stringResource(R.string.manga_tracking_tab) } else { - stringResource(R.string.add_to_library) + pluralStringResource(id = R.plurals.num_trackers, count = trackingCount, trackingCount) }, - icon = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor, - onClick = onAddToLibraryClicked, - onLongClick = onEditCategory, + icon = if (trackingCount == 0) Icons.Outlined.Sync else Icons.Outlined.Done, + color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary, + onClick = onTrackingClicked, ) - if (onTrackingClicked != null) { - AnimeActionButton( - title = if (trackingCount == 0) { - stringResource(R.string.manga_tracking_tab) - } else { - quantityStringResource(id = R.plurals.num_trackers, quantity = trackingCount, trackingCount) - }, - icon = if (trackingCount == 0) Icons.Default.Sync else Icons.Default.Done, - color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary, - onClick = onTrackingClicked, - ) - } - if (onWebViewClicked != null) { - AnimeActionButton( - title = stringResource(R.string.action_web_view), - icon = Icons.Default.Public, - color = defaultActionButtonColor, - onClick = onWebViewClicked, - ) - } } + if (onWebViewClicked != null) { + AnimeActionButton( + title = stringResource(R.string.action_web_view), + icon = Icons.Outlined.Public, + color = defaultActionButtonColor, + onClick = onWebViewClicked, + ) + } + } +} - // Expandable description-tags - Column { - val (expanded, onExpanded) = rememberSaveable { - mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact) - } - val desc = - description.takeIf { !it.isNullOrBlank() } ?: stringResource(id = R.string.description_placeholder) - val trimmedDescription = remember(desc) { - desc - .replace(whitespaceLineRegex, "\n") - .trimEnd() - } - AnimeSummary( - expandedDescription = desc, - shrunkDescription = trimmedDescription, - expanded = expanded, +@Composable +fun ExpandableAnimeDescription( + modifier: Modifier = Modifier, + defaultExpandState: Boolean, + description: String?, + tagsProvider: () -> List?, + onTagClicked: (String) -> Unit, +) { + Column(modifier = modifier) { + val (expanded, onExpanded) = rememberSaveable { + mutableStateOf(defaultExpandState) + } + val desc = + description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder) + val trimmedDescription = remember(desc) { + desc + .replace(whitespaceLineRegex, "\n") + .trimEnd() + } + AnimeSummary( + expandedDescription = desc, + shrunkDescription = trimmedDescription, + expanded = expanded, + modifier = Modifier + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + .clickableNoIndication { onExpanded(!expanded) }, + ) + val tags = tagsProvider() + if (!tags.isNullOrEmpty()) { + Box( modifier = Modifier .padding(top = 8.dp) - .padding(horizontal = 16.dp) - .clickableNoIndication( - onLongClick = { context.copyToClipboard(desc, desc) }, - onClick = { onExpanded(!expanded) }, - ), - ) - val tags = tagsProvider() - if (!tags.isNullOrEmpty()) { - Box( - modifier = Modifier - .padding(top = 8.dp) - .padding(vertical = 12.dp) - .animateContentSize(), - ) { - if (expanded) { - FlowRow( - modifier = Modifier.padding(horizontal = 16.dp), - mainAxisSpacing = 4.dp, - crossAxisSpacing = 8.dp, - ) { - tags.forEach { - TagsChip( - text = it, - onClick = { onTagClicked(it) }, - ) - } + .padding(vertical = 12.dp) + .animateContentSize(), + ) { + if (expanded) { + FlowRow( + modifier = Modifier.padding(horizontal = 16.dp), + mainAxisSpacing = 4.dp, + crossAxisSpacing = 8.dp, + ) { + tags.forEach { + TagsChip( + text = it, + onClick = { onTagClicked(it) }, + ) } - } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(items = tags) { - TagsChip( - text = it, - onClick = { onTagClicked(it) }, - ) - } + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(items = tags) { + TagsChip( + text = it, + onClick = { onTagClicked(it) }, + ) } } } @@ -288,13 +290,14 @@ private fun AnimeAndSourceTitlesLarge( horizontalAlignment = Alignment.CenterHorizontally, ) { MangaCover.Book( - modifier = Modifier.fillMaxWidth(0.4f), + modifier = Modifier.fillMaxWidth(0.65f), data = coverDataProvider(), + contentDescription = stringResource(R.string.manga_cover), onClick = onCoverClick, ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = title.takeIf { it.isNotBlank() } ?: stringResource(R.string.unknown), + text = title.ifBlank { stringResource(R.string.unknown_title) }, style = MaterialTheme.typography.titleLarge, modifier = Modifier.clickableNoIndication( onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) }, @@ -311,10 +314,12 @@ private fun AnimeAndSourceTitlesLarge( .padding(top = 2.dp) .clickableNoIndication( onLongClick = { - if (!author.isNullOrBlank()) context.copyToClipboard( - author, - author, - ) + if (!author.isNullOrBlank()) { + context.copyToClipboard( + author, + author, + ) + } }, onClick = { if (!author.isNullOrBlank()) doSearch(author, true) }, ), @@ -341,13 +346,13 @@ private fun AnimeAndSourceTitlesLarge( ) { Icon( imageVector = when (status) { - SAnime.ONGOING.toLong() -> Icons.Default.Schedule - SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll - SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney - SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done - SAnime.CANCELLED.toLong() -> Icons.Default.Close - SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause - else -> Icons.Default.Block + SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule + SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll + SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney + SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done + SAnime.CANCELLED.toLong() -> Icons.Outlined.Close + SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause + else -> Icons.Outlined.Block }, contentDescription = null, modifier = Modifier @@ -371,7 +376,7 @@ private fun AnimeAndSourceTitlesLarge( DotSeparatorText() if (isStubSource) { Icon( - imageVector = Icons.Default.Warning, + imageVector = Icons.Outlined.Warning, contentDescription = null, modifier = Modifier .padding(end = 4.dp) @@ -415,18 +420,21 @@ private fun AnimeAndSourceTitlesSmall( .sizeIn(maxWidth = 100.dp) .align(Alignment.Top), data = coverDataProvider(), + contentDescription = stringResource(R.string.manga_cover), onClick = onCoverClick, ) Column(modifier = Modifier.padding(start = 16.dp)) { Text( - text = title.ifBlank { stringResource(R.string.unknown) }, + text = title.ifBlank { stringResource(R.string.unknown_title) }, style = MaterialTheme.typography.titleLarge, modifier = Modifier.clickableNoIndication( onLongClick = { - if (title.isNotBlank()) context.copyToClipboard( - title, - title, - ) + if (title.isNotBlank()) { + context.copyToClipboard( + title, + title, + ) + } }, onClick = { if (title.isNotBlank()) doSearch(title, true) }, ), @@ -441,10 +449,12 @@ private fun AnimeAndSourceTitlesSmall( .padding(top = 2.dp) .clickableNoIndication( onLongClick = { - if (!author.isNullOrBlank()) context.copyToClipboard( - author, - author, - ) + if (!author.isNullOrBlank()) { + context.copyToClipboard( + author, + author, + ) + } }, onClick = { if (!author.isNullOrBlank()) doSearch(author, true) }, ), @@ -469,13 +479,13 @@ private fun AnimeAndSourceTitlesSmall( ) { Icon( imageVector = when (status) { - SAnime.ONGOING.toLong() -> Icons.Default.Schedule - SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll - SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney - SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done - SAnime.CANCELLED.toLong() -> Icons.Default.Close - SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause - else -> Icons.Default.Block + SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule + SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll + SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney + SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done + SAnime.CANCELLED.toLong() -> Icons.Outlined.Close + SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause + else -> Icons.Outlined.Block }, contentDescription = null, modifier = Modifier @@ -499,7 +509,7 @@ private fun AnimeAndSourceTitlesSmall( DotSeparatorText() if (isStubSource) { Icon( - imageVector = Icons.Default.Warning, + imageVector = Icons.Outlined.Warning, contentDescription = null, modifier = Modifier .padding(end = 4.dp) @@ -555,13 +565,15 @@ private fun AnimeSummary( expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0 val actualPlaceable = subcompose("description") { - Text( - text = if (expanded) expandedDescription else shrunkDescription, - maxLines = Int.MAX_VALUE, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.secondaryItemAlpha(), - ) + SelectionContainer { + Text( + text = if (expanded) expandedDescription else shrunkDescription, + maxLines = Int.MAX_VALUE, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.secondaryItemAlpha(), + ) + } }.map { it.measure(constraints) } val scrimPlaceable = subcompose("scrim") { @@ -573,7 +585,7 @@ private fun AnimeSummary( val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down) Icon( painter = rememberAnimatedVectorPainter(image, !expanded), - contentDescription = null, + contentDescription = stringResource(if (expanded) R.string.manga_info_collapse else R.string.manga_info_expand), tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())), ) diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSmallAppBar.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSmallAppBar.kt deleted file mode 100644 index d32a18648..000000000 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSmallAppBar.kt +++ /dev/null @@ -1,245 +0,0 @@ -package eu.kanade.presentation.anime.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FlipToBack -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Share -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SmallTopAppBar -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import eu.kanade.presentation.components.DropdownMenu -import eu.kanade.presentation.manga.DownloadAction -import eu.kanade.tachiyomi.R - -@Composable -fun AnimeSmallAppBar( - modifier: Modifier = Modifier, - title: String, - titleAlphaProvider: () -> Float, - backgroundAlphaProvider: () -> Float = titleAlphaProvider, - incognitoMode: Boolean, - downloadedOnlyMode: Boolean, - onBackClicked: () -> Unit, - onShareClicked: (() -> Unit)?, - onDownloadClicked: ((DownloadAction) -> Unit)?, - onEditCategoryClicked: (() -> Unit)?, - changeAnimeSkipIntro: (() -> Unit)?, - onMigrateClicked: (() -> Unit)?, - // For action mode - actionModeCounter: Int, - onSelectAll: () -> Unit, - onInvertSelection: () -> Unit, -) { - val isActionMode = actionModeCounter > 0 - val backgroundAlpha = if (isActionMode) 1f else backgroundAlphaProvider() - val backgroundColor by TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f) - Column( - modifier = modifier.drawBehind { - drawRect(backgroundColor.copy(alpha = backgroundAlpha)) - }, - ) { - SmallTopAppBar( - modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), - title = { - Text( - text = if (isActionMode) actionModeCounter.toString() else title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.alpha(titleAlphaProvider()), - ) - }, - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - imageVector = if (isActionMode) Icons.Default.Close else Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.abc_action_bar_up_description), - ) - } - }, - actions = { - if (isActionMode) { - IconButton(onClick = onSelectAll) { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = stringResource(R.string.action_select_all), - ) - } - IconButton(onClick = onInvertSelection) { - Icon( - imageVector = Icons.Default.FlipToBack, - contentDescription = stringResource(R.string.action_select_inverse), - ) - } - } else { - if (onShareClicked != null) { - IconButton(onClick = onShareClicked) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = stringResource(R.string.action_share), - ) - } - } - - if (onDownloadClicked != null) { - val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) } - Box { - IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = stringResource(R.string.manga_download), - ) - } - val onDismissRequest = { onDownloadExpanded(false) } - DropdownMenu( - expanded = downloadExpanded, - onDismissRequest = onDismissRequest, - ) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_1_episode)) }, - onClick = { - onDownloadClicked(DownloadAction.NEXT_1_CHAPTER) - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_5_episodes)) }, - onClick = { - onDownloadClicked(DownloadAction.NEXT_5_CHAPTERS) - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_10_episodes)) }, - onClick = { - onDownloadClicked(DownloadAction.NEXT_10_CHAPTERS) - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_custom)) }, - onClick = { - onDownloadClicked(DownloadAction.CUSTOM) - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_unseen)) }, - onClick = { - onDownloadClicked(DownloadAction.UNREAD_CHAPTERS) - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download_all)) }, - onClick = { - onDownloadClicked(DownloadAction.ALL_CHAPTERS) - onDismissRequest() - }, - ) - } - } - } - - if (onEditCategoryClicked != null && onMigrateClicked != null) { - val (moreExpanded, onMoreExpanded) = remember { mutableStateOf(false) } - Box { - IconButton(onClick = { onMoreExpanded(!moreExpanded) }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.abc_action_menu_overflow_description), - ) - } - val onDismissRequest = { onMoreExpanded(false) } - DropdownMenu( - expanded = moreExpanded, - onDismissRequest = onDismissRequest, - ) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_edit_anime_categories)) }, - onClick = { - onEditCategoryClicked() - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_migrate)) }, - onClick = { - onMigrateClicked() - onDismissRequest() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_change_intro_length)) }, - onClick = { - changeAnimeSkipIntro?.invoke() - onDismissRequest() - }, - ) - } - } - } - } - }, - // Background handled by parent - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - ), - ) - - if (downloadedOnlyMode) { - Text( - text = stringResource(R.string.label_downloaded_only), - modifier = Modifier - .background(color = MaterialTheme.colorScheme.tertiary) - .fillMaxWidth() - .padding(4.dp), - color = MaterialTheme.colorScheme.onTertiary, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - ) - } - if (incognitoMode) { - Text( - text = stringResource(R.string.pref_incognito_mode), - modifier = Modifier - .background(color = MaterialTheme.colorScheme.primary) - .fillMaxWidth() - .padding(4.dp), - color = MaterialTheme.colorScheme.onPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - ) - } - } -} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeTopAppBar.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeTopAppBar.kt deleted file mode 100644 index 10777eb48..000000000 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeTopAppBar.kt +++ /dev/null @@ -1,145 +0,0 @@ -package eu.kanade.presentation.anime.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Constraints -import eu.kanade.domain.anime.model.Anime -import eu.kanade.presentation.manga.DownloadAction -import kotlin.math.roundToInt - -@Composable -fun AnimeTopAppBar( - modifier: Modifier = Modifier, - title: String, - author: String?, - artist: String?, - description: String?, - tagsProvider: () -> List?, - coverDataProvider: () -> Anime, - sourceName: String, - isStubSource: Boolean, - favorite: Boolean, - status: Long, - trackingCount: Int, - episodeCount: Int?, - episodeFiltered: Boolean, - incognitoMode: Boolean, - downloadedOnlyMode: Boolean, - fromSource: Boolean, - onBackClicked: () -> Unit, - onCoverClick: () -> Unit, - onTagClicked: (String) -> Unit, - onAddToLibraryClicked: () -> Unit, - onWebViewClicked: (() -> Unit)?, - onTrackingClicked: (() -> Unit)?, - onFilterButtonClicked: () -> Unit, - onShareClicked: (() -> Unit)?, - onDownloadClicked: ((DownloadAction) -> Unit)?, - onEditCategoryClicked: (() -> Unit)?, - onMigrateClicked: (() -> Unit)?, - changeAnimeSkipIntro: (() -> Unit)?, - doGlobalSearch: (query: String, global: Boolean) -> Unit, - scrollBehavior: TopAppBarScrollBehavior?, - // For action mode - actionModeCounter: Int, - onSelectAll: () -> Unit, - onInvertSelection: () -> Unit, - onSmallAppBarHeightChanged: (Int) -> Unit, - -) { - val scrollPercentageProvider = { scrollBehavior?.scrollFraction?.coerceIn(0f, 1f) ?: 0f } - val inverseScrollPercentageProvider = { 1f - scrollPercentageProvider() } - - Layout( - modifier = modifier, - content = { - val (smallHeightPx, onSmallHeightPxChanged) = remember { mutableStateOf(0) } - Column(modifier = Modifier.layoutId("animeInfo")) { - AnimeInfoHeader( - windowWidthSizeClass = WindowWidthSizeClass.Compact, - appBarPadding = with(LocalDensity.current) { smallHeightPx.toDp() }, - title = title, - author = author, - artist = artist, - description = description, - tagsProvider = tagsProvider, - sourceName = sourceName, - isStubSource = isStubSource, - coverDataProvider = coverDataProvider, - favorite = favorite, - status = status, - trackingCount = trackingCount, - fromSource = fromSource, - onAddToLibraryClicked = onAddToLibraryClicked, - onWebViewClicked = onWebViewClicked, - onTrackingClicked = onTrackingClicked, - onTagClicked = onTagClicked, - onEditCategory = onEditCategoryClicked, - onCoverClick = onCoverClick, - doSearch = doGlobalSearch, - ) - EpisodeHeader( - episodeCount = episodeCount, - isEpisodeFiltered = episodeFiltered, - onFilterButtonClicked = onFilterButtonClicked, - ) - } - - AnimeSmallAppBar( - modifier = Modifier - .layoutId("topBar") - .onSizeChanged { - onSmallHeightPxChanged(it.height) - onSmallAppBarHeightChanged(it.height) - }, - title = title, - titleAlphaProvider = { if (actionModeCounter == 0) scrollPercentageProvider() else 1f }, - incognitoMode = incognitoMode, - downloadedOnlyMode = downloadedOnlyMode, - onBackClicked = onBackClicked, - onShareClicked = onShareClicked, - onDownloadClicked = onDownloadClicked, - onEditCategoryClicked = onEditCategoryClicked, - onMigrateClicked = onMigrateClicked, - changeAnimeSkipIntro = changeAnimeSkipIntro, - actionModeCounter = actionModeCounter, - onSelectAll = onSelectAll, - onInvertSelection = onInvertSelection, - - ) - }, - ) { measurables, constraints -> - val animeInfoPlaceable = measurables - .first { it.layoutId == "animeInfo" } - .measure(constraints.copy(maxHeight = Constraints.Infinity)) - val topBarPlaceable = measurables - .first { it.layoutId == "topBar" } - .measure(constraints) - val animeInfoHeight = animeInfoPlaceable.height - val topBarHeight = topBarPlaceable.height - val animeInfoSansTopBarHeightPx = animeInfoHeight - topBarHeight - val layoutHeight = topBarHeight + - (animeInfoSansTopBarHeightPx * inverseScrollPercentageProvider()).roundToInt() - - layout(constraints.maxWidth, layoutHeight) { - val animeInfoY = (-animeInfoSansTopBarHeightPx * scrollPercentageProvider()).roundToInt() - animeInfoPlaceable.place(0, animeInfoY) - topBarPlaceable.place(0, 0) - - // Update offset limit - val offsetLimit = -animeInfoSansTopBarHeightPx.toFloat() - if (scrollBehavior?.state?.offsetLimit != offsetLimit) { - scrollBehavior?.state?.offsetLimit = offsetLimit - } - } - } -} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt index 1a8931053..e9f693884 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt @@ -4,32 +4,25 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.Icon import androidx.compose.material3.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.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import eu.kanade.presentation.util.quantityStringResource import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.system.getResourceColor @Composable fun EpisodeHeader( episodeCount: Int?, - isEpisodeFiltered: Boolean, - onFilterButtonClicked: () -> Unit, + onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onFilterButtonClicked) + .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -37,20 +30,11 @@ fun EpisodeHeader( text = if (episodeCount == null) { stringResource(R.string.episodes) } else { - quantityStringResource(id = R.plurals.anime_num_episodes, quantity = episodeCount) + pluralStringResource(id = R.plurals.anime_num_episodes, count = episodeCount, episodeCount) }, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onBackground, ) - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = stringResource(R.string.action_filter), - tint = if (isEpisodeFiltered) { - Color(LocalContext.current.getResourceColor(R.attr.colorFilterActive)) - } else { - MaterialTheme.colorScheme.onBackground - }, - ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsScreen.kt similarity index 59% rename from app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt rename to app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsScreen.kt index b71e37e81..96ea8fd60 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsScreen.kt @@ -1,25 +1,27 @@ -package eu.kanade.presentation.browse +package eu.kanade.presentation.animebrowse +import android.content.Intent +import android.net.Uri +import android.provider.Settings import android.util.DisplayMetrics import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Button import androidx.compose.material3.Divider @@ -30,117 +32,179 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.animebrowse.components.AnimeExtensionIcon +import eu.kanade.presentation.browse.NsfwWarningDialog +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DIVIDER_ALPHA import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.util.horizontalPadding import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource -import eu.kanade.tachiyomi.extension.model.AnimeExtension import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionDetailsPresenter import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem import eu.kanade.tachiyomi.util.system.LocaleHelper @Composable fun AnimeExtensionDetailsScreen( - nestedScrollInterop: NestedScrollConnection, + navigateUp: () -> Unit, presenter: AnimeExtensionDetailsPresenter, - onClickUninstall: () -> Unit, - onClickAppInfo: () -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit, - onClickSource: (sourceId: Long) -> Unit, ) { - val extension = presenter.extension + val uriHandler = LocalUriHandler.current - if (extension == null) { - EmptyScreen(textResource = R.string.empty_screen) - return - } - - val sources by presenter.sourcesState.collectAsState() - - var showNsfwWarning by remember { mutableStateOf(false) } - - ScrollbarLazyColumn( - modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - when { - extension.isUnofficial -> - item { - WarningBanner(R.string.unofficial_extension_message_aniyomi) - } - extension.isObsolete -> - item { - WarningBanner(R.string.obsolete_extension_message) - } - } - - item { - DetailsHeader( - extension = extension, - onClickUninstall = onClickUninstall, - onClickAppInfo = onClickAppInfo, - onClickAgeRating = { - showNsfwWarning = true + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.label_extension_info), + navigateUp = navigateUp, + actions = { + AppBarActions( + actions = buildList { + if (presenter.extension?.isUnofficial == false) { + add( + AppBar.Action( + title = stringResource(R.string.whats_new), + icon = Icons.Outlined.History, + onClick = { uriHandler.openUri(presenter.getChangelogUrl()) }, + ), + ) + add( + AppBar.Action( + title = stringResource(R.string.action_faq_and_guides), + icon = Icons.Outlined.HelpOutline, + onClick = { uriHandler.openUri(presenter.getReadmeUrl()) }, + ), + ) + } + addAll( + listOf( + AppBar.OverflowAction( + title = stringResource(R.string.action_enable_all), + onClick = { presenter.toggleSources(true) }, + ), + AppBar.OverflowAction( + title = stringResource(R.string.action_disable_all), + onClick = { presenter.toggleSources(false) }, + ), + AppBar.OverflowAction( + title = stringResource(R.string.pref_clear_cookies), + onClick = { presenter.clearCookies() }, + ), + ), + ) + }, + ) }, + scrollBehavior = scrollBehavior, ) - } - - items( - items = sources, - key = { it.source.id }, - ) { source -> - SourceSwitchPreference( - modifier = Modifier.animateItemPlacement(), - source = source, - onClickSourcePreferences = onClickSourcePreferences, - onClickSource = onClickSource, - ) - } + }, + ) { paddingValues -> + AnimeExtensionDetails(paddingValues, presenter, onClickSourcePreferences) } - if (showNsfwWarning) { - NsfwWarningDialog( - onClickConfirm = { - showNsfwWarning = false - }, +} + +@Composable +private fun AnimeExtensionDetails( + contentPadding: PaddingValues, + presenter: AnimeExtensionDetailsPresenter, + onClickSourcePreferences: (sourceId: Long) -> Unit, +) { + when { + presenter.isLoading -> LoadingScreen() + presenter.extension == null -> EmptyScreen( + textResource = R.string.empty_screen, + modifier = Modifier.padding(contentPadding), ) + else -> { + val context = LocalContext.current + val extension = presenter.extension + var showNsfwWarning by remember { mutableStateOf(false) } + + ScrollbarLazyColumn( + contentPadding = contentPadding, + ) { + when { + extension.isUnofficial -> + item { + WarningBanner(R.string.unofficial_extension_message_aniyomi) + } + extension.isObsolete -> + item { + WarningBanner(R.string.obsolete_extension_message) + } + } + + item { + DetailsHeader( + extension = extension, + onClickUninstall = { presenter.uninstallExtension() }, + onClickAppInfo = { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", extension.pkgName, null) + context.startActivity(this) + } + }, + onClickAgeRating = { + showNsfwWarning = true + }, + ) + } + + items( + items = presenter.sources, + key = { it.source.id }, + ) { source -> + SourceSwitchPreference( + modifier = Modifier.animateItemPlacement(), + source = source, + onClickSourcePreferences = onClickSourcePreferences, + onClickSource = { presenter.toggleSource(it) }, + ) + } + } + if (showNsfwWarning) { + NsfwWarningDialog( + onClickConfirm = { + showNsfwWarning = false + }, + ) + } + } } } @Composable private fun WarningBanner(@StringRes textRes: Int) { - Box( + Text( + text = stringResource(textRes), modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.error) .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(textRes), - color = MaterialTheme.colorScheme.onError, - style = MaterialTheme.typography.bodyMedium, - ) - } + color = MaterialTheme.colorScheme.onError, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) } @Composable @@ -164,7 +228,7 @@ private fun DetailsHeader( ), horizontalAlignment = Alignment.CenterHorizontally, ) { - ExtensionIcon( + AnimeExtensionIcon( modifier = Modifier .size(112.dp), extension = extension, @@ -268,7 +332,9 @@ private fun InfoText( val clickableModifier = if (onClick != null) { Modifier.clickable(interactionSource, indication = null) { onClick() } - } else Modifier + } else { + Modifier + } Column( modifier = modifier.then(clickableModifier), diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsState.kt new file mode 100644 index 000000000..85c16a6a1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionDetailsState.kt @@ -0,0 +1,25 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem + +@Stable +interface AnimeExtensionDetailsState { + val isLoading: Boolean + val extension: AnimeExtension.Installed? + val sources: List +} + +fun AnimeExtensionDetailsState(): AnimeExtensionDetailsState { + return AnimeExtensionDetailsStateImpl() +} + +class AnimeExtensionDetailsStateImpl : AnimeExtensionDetailsState { + override var isLoading: Boolean by mutableStateOf(true) + override var extension: AnimeExtension.Installed? by mutableStateOf(null) + override var sources: List by mutableStateOf(emptyList()) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterScreen.kt new file mode 100644 index 000000000..90e06948f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterScreen.kt @@ -0,0 +1,104 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionFilterPresenter +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun AnimeExtensionFilterScreen( + navigateUp: () -> Unit, + presenter: AnimeExtensionFilterPresenter, +) { + val context = LocalContext.current + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.label_extensions), + navigateUp = navigateUp, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.empty_screen, + modifier = Modifier.padding(contentPadding), + ) + else -> { + SourceFilterContent( + contentPadding = contentPadding, + state = presenter, + onClickLang = { + presenter.toggleLanguage(it) + }, + ) + } + } + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { + when (it) { + AnimeExtensionFilterPresenter.Event.FailedFetchingLanguages -> { + context.toast(R.string.internal_error) + } + } + } + } +} + +@Composable +private fun SourceFilterContent( + contentPadding: PaddingValues, + state: AnimeExtensionFilterState, + onClickLang: (String) -> Unit, +) { + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + items( + items = state.items, + ) { model -> + ExtensionFilterItem( + modifier = Modifier.animateItemPlacement(), + lang = model.lang, + enabled = model.enabled, + onClickItem = onClickLang, + ) + } + } +} + +@Composable +private fun ExtensionFilterItem( + modifier: Modifier, + lang: String, + enabled: Boolean, + onClickItem: (String) -> Unit, +) { + PreferenceRow( + modifier = modifier, + title = LocaleHelper.getSourceDisplayName(lang, LocalContext.current), + action = { + Switch(checked = enabled, onCheckedChange = null) + }, + onClick = { onClickItem(lang) }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterState.kt new file mode 100644 index 000000000..914e0c0d2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionFilterState.kt @@ -0,0 +1,25 @@ +package eu.kanade.presentation.animebrowse + +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.kanade.tachiyomi.ui.browse.animeextension.AnimeFilterUiModel + +@Stable +interface AnimeExtensionFilterState { + val isLoading: Boolean + val items: List + val isEmpty: Boolean +} + +fun AnimeExtensionFilterState(): AnimeExtensionFilterState { + return AnimeExtensionFilterStateImpl() +} + +class AnimeExtensionFilterStateImpl : AnimeExtensionFilterState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsScreen.kt similarity index 56% rename from app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt rename to app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsScreen.kt index aaf1cf10a..20a85d638 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsScreen.kt @@ -1,59 +1,62 @@ -package eu.kanade.presentation.browse +package eu.kanade.presentation.animebrowse +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.google.accompanist.flowlayout.FlowRow +import eu.kanade.presentation.animebrowse.components.AnimeExtensionIcon +import eu.kanade.presentation.browse.ExtensionHeader +import eu.kanade.presentation.browse.ExtensionTrustDialog import eu.kanade.presentation.browse.components.BaseBrowseItem -import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.FastScrollLazyColumn -import eu.kanade.presentation.components.SwipeRefreshIndicator +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.SwipeRefresh +import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionUiModel import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionsPresenter -import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionState -import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionUiModel import eu.kanade.tachiyomi.util.system.LocaleHelper @Composable fun AnimeExtensionScreen( - nestedScrollInterop: NestedScrollConnection, presenter: AnimeExtensionsPresenter, + contentPadding: PaddingValues, onLongClickItem: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit, onInstallExtension: (AnimeExtension.Available) -> Unit, @@ -63,21 +66,22 @@ fun AnimeExtensionScreen( onOpenExtension: (AnimeExtension.Installed) -> Unit, onClickUpdateAll: () -> Unit, onRefresh: () -> Unit, - onLaunched: () -> Unit, ) { - val state by presenter.state.collectAsState() - val isRefreshing = presenter.isRefreshing - SwipeRefresh( - modifier = Modifier.nestedScroll(nestedScrollInterop), - state = rememberSwipeRefreshState(isRefreshing), - indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) }, + refreshing = presenter.isRefreshing, onRefresh = onRefresh, + enabled = !presenter.isLoading, ) { - when (state) { - is ExtensionState.Initialized -> { - ExtensionContent( - items = (state as ExtensionState.Initialized).list, + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.empty_screen, + modifier = Modifier.padding(contentPadding), + ) + else -> { + AnimeExtensionContent( + state = presenter, + contentPadding = contentPadding, onLongClickItem = onLongClickItem, onClickItemCancel = onClickItemCancel, onInstallExtension = onInstallExtension, @@ -86,17 +90,16 @@ fun AnimeExtensionScreen( onTrustExtension = onTrustExtension, onOpenExtension = onOpenExtension, onClickUpdateAll = onClickUpdateAll, - onLaunched = onLaunched, ) } - ExtensionState.Uninitialized -> {} } } } @Composable -fun ExtensionContent( - items: List, +private fun AnimeExtensionContent( + state: AnimeExtensionsState, + contentPadding: PaddingValues, onLongClickItem: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit, onInstallExtension: (AnimeExtension.Available) -> Unit, @@ -105,31 +108,29 @@ fun ExtensionContent( onTrustExtension: (AnimeExtension.Untrusted) -> Unit, onOpenExtension: (AnimeExtension.Installed) -> Unit, onClickUpdateAll: () -> Unit, - onLaunched: () -> Unit, ) { var trustState by remember { mutableStateOf(null) } FastScrollLazyColumn( - contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, + contentPadding = contentPadding + topPaddingValues, ) { items( - items = items, - key = { - when (it) { - is ExtensionUiModel.Header.Resource -> it.textRes - is ExtensionUiModel.Header.Text -> it.text - is ExtensionUiModel.Item -> it.key() - } - }, + items = state.items, contentType = { when (it) { - is ExtensionUiModel.Item -> "item" - else -> "header" + is AnimeExtensionUiModel.Header -> "header" + is AnimeExtensionUiModel.Item -> "item" + } + }, + key = { + when (it) { + is AnimeExtensionUiModel.Header -> "animeextensionHeader-${it.hashCode()}" + is AnimeExtensionUiModel.Item -> "animeextension-${it.hashCode()}" } }, ) { item -> when (item) { - is ExtensionUiModel.Header.Resource -> { + is AnimeExtensionUiModel.Header.Resource -> { val action: @Composable RowScope.() -> Unit = if (item.textRes == R.string.ext_updates_pending) { { @@ -151,26 +152,20 @@ fun ExtensionContent( action = action, ) } - is ExtensionUiModel.Header.Text -> { + is AnimeExtensionUiModel.Header.Text -> { ExtensionHeader( text = item.text, modifier = Modifier.animateItemPlacement(), ) } - is ExtensionUiModel.Item -> { + is AnimeExtensionUiModel.Item -> { AnimeExtensionItem( modifier = Modifier.animateItemPlacement(), item = item, onClickItem = { when (it) { is AnimeExtension.Available -> onInstallExtension(it) - is AnimeExtension.Installed -> { - if (it.hasUpdate) { - onUpdateExtension(it) - } else { - onOpenExtension(it) - } - } + is AnimeExtension.Installed -> onOpenExtension(it) is AnimeExtension.Untrusted -> { trustState = it } } }, @@ -190,9 +185,6 @@ fun ExtensionContent( } }, ) - LaunchedEffect(Unit) { - onLaunched() - } } } } @@ -215,9 +207,9 @@ fun ExtensionContent( } @Composable -fun AnimeExtensionItem( +private fun AnimeExtensionItem( modifier: Modifier = Modifier, - item: ExtensionUiModel.Item, + item: AnimeExtensionUiModel.Item, onClickItem: (AnimeExtension) -> Unit, onLongClickItem: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit, @@ -233,10 +225,30 @@ fun AnimeExtensionItem( onClickItem = { onClickItem(extension) }, onLongClickItem = { onLongClickItem(extension) }, icon = { - ExtensionIcon(extension = extension) + Box( + modifier = Modifier + .size(40.dp), + contentAlignment = Alignment.Center, + ) { + val idle = installStep.isCompleted() + if (!idle) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + strokeWidth = 2.dp, + ) + } + + val padding by animateDpAsState(targetValue = if (idle) 0.dp else 8.dp) + AnimeExtensionIcon( + extension = extension, + modifier = Modifier + .matchParentSize() + .padding(padding), + ) + } }, action = { - ExtensionItemActions( + AnimeExtensionItemActions( extension = extension, installStep = installStep, onClickItemCancel = onClickItemCancel, @@ -244,29 +256,20 @@ fun AnimeExtensionItem( ) }, ) { - ExtensionItemContent( + AnimeExtensionItemContent( extension = extension, + installStep = installStep, modifier = Modifier.weight(1f), ) } } @Composable -fun ExtensionItemContent( +private fun AnimeExtensionItemContent( extension: AnimeExtension, + installStep: InstallStep, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val warning = remember(extension) { - when { - extension is AnimeExtension.Untrusted -> R.string.ext_untrusted - extension is AnimeExtension.Installed && extension.isUnofficial -> R.string.ext_unofficial - extension is AnimeExtension.Installed && extension.isObsolete -> R.string.ext_obsolete - extension.isNsfw -> R.string.ext_nsfw_short - else -> null - } - } - Column( modifier = modifier.padding(start = horizontalPadding), ) { @@ -276,83 +279,96 @@ fun ExtensionItemContent( overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + // Won't look good but it's not like we can ellipsize overflowing content + FlowRow( + modifier = Modifier.secondaryItemAlpha(), + mainAxisSpacing = 4.dp, ) { - if (extension.lang.isNullOrEmpty().not()) { - Text( - text = LocaleHelper.getSourceDisplayName(extension.lang, context), - style = MaterialTheme.typography.bodySmall, - ) - } + ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { + if (extension is AnimeExtension.Installed && extension.lang.isNotEmpty()) { + Text( + text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current), + ) + } - if (extension.versionName.isNotEmpty()) { - Text( - text = extension.versionName, - style = MaterialTheme.typography.bodySmall, - ) - } + if (extension.versionName.isNotEmpty()) { + Text( + text = extension.versionName, + ) + } - if (warning != null) { - Text( - text = stringResource(id = warning).uppercase(), - style = MaterialTheme.typography.bodySmall.copy( + val warning = when { + extension is AnimeExtension.Untrusted -> R.string.ext_untrusted + extension is AnimeExtension.Installed && extension.isUnofficial -> R.string.ext_unofficial + extension is AnimeExtension.Installed && extension.isObsolete -> R.string.ext_obsolete + extension.isNsfw -> R.string.ext_nsfw_short + else -> null + } + if (warning != null) { + Text( + text = stringResource(warning).uppercase(), color = MaterialTheme.colorScheme.error, - ), - ) + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!installStep.isCompleted()) { + DotSeparatorNoSpaceText() + Text( + text = when (installStep) { + InstallStep.Pending -> stringResource(R.string.ext_pending) + InstallStep.Downloading -> stringResource(R.string.ext_downloading) + InstallStep.Installing -> stringResource(R.string.ext_installing) + else -> error("Must not show non-install process text") + }, + ) + } } } } } @Composable -fun ExtensionItemActions( +private fun AnimeExtensionItemActions( extension: AnimeExtension, installStep: InstallStep, modifier: Modifier = Modifier, onClickItemCancel: (AnimeExtension) -> Unit = {}, onClickItemAction: (AnimeExtension) -> Unit = {}, ) { - val isIdle = remember(installStep) { - installStep == InstallStep.Idle || installStep == InstallStep.Error - } + val isIdle = installStep.isCompleted() Row(modifier = modifier) { - TextButton( - onClick = { onClickItemAction(extension) }, - enabled = isIdle, - ) { - Text( - text = when (installStep) { - InstallStep.Pending -> stringResource(R.string.ext_pending) - InstallStep.Downloading -> stringResource(R.string.ext_downloading) - InstallStep.Installing -> stringResource(R.string.ext_installing) - InstallStep.Installed -> stringResource(R.string.ext_installed) - InstallStep.Error -> stringResource(R.string.action_retry) - InstallStep.Idle -> { - when (extension) { - is AnimeExtension.Installed -> { - if (extension.hasUpdate) { - stringResource(R.string.ext_update) - } else { - stringResource(R.string.action_settings) + if (isIdle) { + TextButton( + onClick = { onClickItemAction(extension) }, + ) { + Text( + text = when (installStep) { + InstallStep.Installed -> stringResource(R.string.ext_installed) + InstallStep.Error -> stringResource(R.string.action_retry) + InstallStep.Idle -> { + when (extension) { + is AnimeExtension.Installed -> { + if (extension.hasUpdate) { + stringResource(R.string.ext_update) + } else { + stringResource(R.string.action_settings) + } } + is AnimeExtension.Untrusted -> stringResource(R.string.ext_trust) + is AnimeExtension.Available -> stringResource(R.string.ext_install) } - is AnimeExtension.Untrusted -> stringResource(R.string.ext_trust) - is AnimeExtension.Available -> stringResource(R.string.ext_install) } - } - }, - style = LocalTextStyle.current.copy( - color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint, - ), - ) - } - if (isIdle.not()) { + else -> error("Must not show install process text") + }, + ) + } + } else { IconButton(onClick = { onClickItemCancel(extension) }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = "", - tint = MaterialTheme.colorScheme.onBackground, + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.action_cancel), ) } } diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsState.kt new file mode 100644 index 000000000..0efc1a9e4 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeExtensionsState.kt @@ -0,0 +1,27 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionUiModel + +interface AnimeExtensionsState { + val isLoading: Boolean + val isRefreshing: Boolean + val items: List + val updates: Int + val isEmpty: Boolean +} + +fun AnimeExtensionState(): AnimeExtensionsState { + return AnimeExtensionsStateImpl() +} + +class AnimeExtensionsStateImpl : AnimeExtensionsState { + override var isLoading: Boolean by mutableStateOf(true) + override var isRefreshing: Boolean by mutableStateOf(false) + override var items: List by mutableStateOf(emptyList()) + override var updates: Int by mutableStateOf(0) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourceSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourceSearchScreen.kt new file mode 100644 index 000000000..cb4dbaf56 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourceSearchScreen.kt @@ -0,0 +1,77 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.material3.SnackbarHost +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.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.paging.compose.collectAsLazyPagingItems +import eu.kanade.domain.anime.model.Anime +import eu.kanade.presentation.browse.BrowseSourceFloatingActionButton +import eu.kanade.presentation.browse.components.BrowseSourceSearchToolbar +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.LocalAnimeSource +import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourcePresenter +import eu.kanade.tachiyomi.ui.more.MoreController + +@Composable +fun AnimeSourceSearchScreen( + presenter: BrowseAnimeSourcePresenter, + navigateUp: () -> Unit, + onFabClick: () -> Unit, + onAnimeClick: (Anime) -> Unit, + onWebViewClick: () -> Unit, +) { + val columns by presenter.getColumnsPreferenceForCurrentOrientation() + + val mangaList = presenter.getAnimeList().collectAsLazyPagingItems() + + val snackbarHostState = remember { SnackbarHostState() } + + val uriHandler = LocalUriHandler.current + + val onHelpClick = { + uriHandler.openUri(LocalAnimeSource.HELP_URL) + } + + Scaffold( + topBar = { scrollBehavior -> + BrowseSourceSearchToolbar( + searchQuery = presenter.searchQuery ?: "", + onSearchQueryChanged = { presenter.searchQuery = it }, + placeholderText = stringResource(R.string.action_search_hint), + navigateUp = navigateUp, + onResetClick = { presenter.searchQuery = "" }, + onSearchClick = { presenter.search(it) }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + BrowseSourceFloatingActionButton( + isVisible = presenter.filters.isNotEmpty(), + onFabClick = onFabClick, + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { paddingValues -> + BrowseAnimeSourceContent( + state = presenter, + animeList = mangaList, + getAnimeState = { presenter.getAnime(it) }, + columns = columns, + displayMode = presenter.displayMode, + snackbarHostState = snackbarHostState, + contentPadding = paddingValues, + onWebViewClick = onWebViewClick, + onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, + onLocalAnimeSourceHelpClick = onHelpClick, + onAnimeClick = onAnimeClick, + onAnimeLongClick = onAnimeClick, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterScreen.kt new file mode 100644 index 000000000..eea8a255c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterScreen.kt @@ -0,0 +1,165 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.presentation.animebrowse.components.BaseAnimeSourceItem +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.animesource.AnimeFilterUiModel +import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesFilterPresenter +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun AnimeSourcesFilterScreen( + navigateUp: () -> Unit, + presenter: AnimeSourcesFilterPresenter, + onClickLang: (String) -> Unit, + onClickSource: (AnimeSource) -> Unit, +) { + val context = LocalContext.current + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.label_sources), + navigateUp = navigateUp, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.source_filter_empty_screen, + modifier = Modifier.padding(contentPadding), + ) + else -> { + AnimeSourcesFilterContent( + contentPadding = contentPadding, + state = presenter, + onClickLang = onClickLang, + onClickSource = onClickSource, + ) + } + } + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + AnimeSourcesFilterPresenter.Event.FailedFetchingLanguages -> { + context.toast(R.string.internal_error) + } + } + } + } +} + +@Composable +private fun AnimeSourcesFilterContent( + contentPadding: PaddingValues, + state: AnimeSourcesFilterState, + onClickLang: (String) -> Unit, + onClickSource: (AnimeSource) -> Unit, +) { + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + items( + items = state.items, + contentType = { + when (it) { + is AnimeFilterUiModel.Header -> "header" + is AnimeFilterUiModel.Item -> "item" + } + }, + key = { + when (it) { + is AnimeFilterUiModel.Header -> it.hashCode() + is AnimeFilterUiModel.Item -> "source-filter-${it.source.key()}" + } + }, + ) { model -> + when (model) { + is AnimeFilterUiModel.Header -> AnimeSourcesFilterHeader( + modifier = Modifier.animateItemPlacement(), + language = model.language, + enabled = model.enabled, + onClickItem = onClickLang, + ) + is AnimeFilterUiModel.Item -> AnimeSourcesFilterItem( + modifier = Modifier.animateItemPlacement(), + source = model.source, + isEnabled = model.isEnabled, + onClickItem = onClickSource, + ) + } + } + } +} + +@Composable +fun AnimeSourceFilterHeader( + modifier: Modifier, + language: String, + isEnabled: Boolean, + onClickItem: (String) -> Unit, +) { + PreferenceRow( + modifier = modifier, + title = LocaleHelper.getSourceDisplayName(language, LocalContext.current), + action = { + Switch(checked = isEnabled, onCheckedChange = null) + }, + onClick = { onClickItem(language) }, + ) +} + +@Composable +private fun AnimeSourcesFilterHeader( + modifier: Modifier, + language: String, + enabled: Boolean, + onClickItem: (String) -> Unit, +) { + PreferenceRow( + modifier = modifier, + title = LocaleHelper.getSourceDisplayName(language, LocalContext.current), + action = { + Switch(checked = enabled, onCheckedChange = null) + }, + onClick = { onClickItem(language) }, + ) +} + +@Composable +private fun AnimeSourcesFilterItem( + modifier: Modifier, + source: AnimeSource, + isEnabled: Boolean, + onClickItem: (AnimeSource) -> Unit, +) { + BaseAnimeSourceItem( + modifier = modifier, + source = source, + showLanguageInContent = false, + onClickItem = { onClickItem(source) }, + action = { + Checkbox(checked = isEnabled, onCheckedChange = null) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterState.kt new file mode 100644 index 000000000..d5c9ae83c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesFilterState.kt @@ -0,0 +1,23 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.tachiyomi.ui.browse.animesource.AnimeFilterUiModel + +interface AnimeSourcesFilterState { + val isLoading: Boolean + val items: List + val isEmpty: Boolean +} + +fun AnimeSourcesFilterState(): AnimeSourcesFilterState { + return AnimeSourcesFilterStateImpl() +} + +class AnimeSourcesFilterStateImpl : AnimeSourcesFilterState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeSourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesScreen.kt similarity index 58% rename from app/src/main/java/eu/kanade/presentation/browse/AnimeSourcesScreen.kt rename to app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesScreen.kt index f08a8ab09..0bbfc48fd 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/AnimeSourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesScreen.kt @@ -1,16 +1,10 @@ -package eu.kanade.presentation.browse +package eu.kanade.presentation.animebrowse -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PushPin @@ -23,73 +17,78 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import eu.kanade.domain.animesource.interactor.GetRemoteAnime import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.Pin -import eu.kanade.presentation.browse.components.BaseAnimeSourceItem +import eu.kanade.presentation.animebrowse.components.BaseAnimeSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.theme.header +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.LocalAnimeSource -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceState import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesPresenter +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest @Composable fun AnimeSourcesScreen( - nestedScrollInterop: NestedScrollConnection, presenter: AnimeSourcesPresenter, - onClickItem: (AnimeSource) -> Unit, + contentPadding: PaddingValues, + onClickItem: (AnimeSource, String) -> Unit, onClickDisable: (AnimeSource) -> Unit, - onClickLatest: (AnimeSource) -> Unit, onClickPin: (AnimeSource) -> Unit, ) { - val state by presenter.state.collectAsState() - - when (state) { - is AnimeSourceState.Loading -> LoadingScreen() - is AnimeSourceState.Error -> Text(text = (state as AnimeSourceState.Error).error.message!!) - is AnimeSourceState.Success -> AnimeSourceList( - nestedScrollConnection = nestedScrollInterop, - list = (state as AnimeSourceState.Success).uiModels, - onClickItem = onClickItem, - onClickDisable = onClickDisable, - onClickLatest = onClickLatest, - onClickPin = onClickPin, + val context = LocalContext.current + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.source_empty_screen, + modifier = Modifier.padding(contentPadding), ) + else -> { + AnimeSourceList( + state = presenter, + contentPadding = contentPadding, + onClickItem = onClickItem, + onClickDisable = onClickDisable, + onClickPin = onClickPin, + ) + } + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + AnimeSourcesPresenter.Event.FailedFetchingSources -> { + context.toast(R.string.internal_error) + } + } + } } } @Composable -fun AnimeSourceList( - nestedScrollConnection: NestedScrollConnection, - list: List, - onClickItem: (AnimeSource) -> Unit, +private fun AnimeSourceList( + state: AnimeSourcesState, + contentPadding: PaddingValues, + onClickItem: (AnimeSource, String) -> Unit, onClickDisable: (AnimeSource) -> Unit, - onClickLatest: (AnimeSource) -> Unit, onClickPin: (AnimeSource) -> Unit, ) { - if (list.isEmpty()) { - EmptyScreen(textResource = R.string.source_empty_screen) - return - } - - val (sourceState, setSourceState) = remember { mutableStateOf(null) } - LazyColumn( - modifier = Modifier - .nestedScroll(nestedScrollConnection), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ScrollbarLazyColumn( + contentPadding = contentPadding + topPaddingValues, ) { items( - items = list, + items = state.items, contentType = { when (it) { is AnimeSourceUiModel.Header -> "header" @@ -99,13 +98,13 @@ fun AnimeSourceList( key = { when (it) { is AnimeSourceUiModel.Header -> it.hashCode() - is AnimeSourceUiModel.Item -> it.source.key() + is AnimeSourceUiModel.Item -> "source-${it.source.key()}" } }, ) { model -> when (model) { is AnimeSourceUiModel.Header -> { - SourceHeader( + AnimeSourceHeader( modifier = Modifier.animateItemPlacement(), language = model.language, ) @@ -114,49 +113,60 @@ fun AnimeSourceList( modifier = Modifier.animateItemPlacement(), source = model.source, onClickItem = onClickItem, - onLongClickItem = { - setSourceState(it) - }, - onClickLatest = onClickLatest, + onLongClickItem = { state.dialog = AnimeSourcesPresenter.Dialog(it) }, onClickPin = onClickPin, ) } } } - if (sourceState != null) { + if (state.dialog != null) { + val source = state.dialog!!.source AnimeSourceOptionsDialog( - source = sourceState, + source = source, onClickPin = { - onClickPin(sourceState) - setSourceState(null) + onClickPin(source) + state.dialog = null }, onClickDisable = { - onClickDisable(sourceState) - setSourceState(null) + onClickDisable(source) + state.dialog = null }, - onDismiss = { setSourceState(null) }, + onDismiss = { state.dialog = null }, ) } } @Composable -fun AnimeSourceItem( +private fun AnimeSourceHeader( + modifier: Modifier = Modifier, + language: String, +) { + val context = LocalContext.current + Text( + text = LocaleHelper.getSourceDisplayName(language, context), + modifier = modifier + .padding(horizontal = horizontalPadding, vertical = 8.dp), + style = MaterialTheme.typography.header, + ) +} + +@Composable +private fun AnimeSourceItem( modifier: Modifier = Modifier, source: AnimeSource, - onClickItem: (AnimeSource) -> Unit, + onClickItem: (AnimeSource, String) -> Unit, onLongClickItem: (AnimeSource) -> Unit, - onClickLatest: (AnimeSource) -> Unit, onClickPin: (AnimeSource) -> Unit, ) { BaseAnimeSourceItem( modifier = modifier, source = source, - onClickItem = { onClickItem(source) }, + onClickItem = { onClickItem(source, GetRemoteAnime.QUERY_POPULAR) }, onLongClickItem = { onLongClickItem(source) }, - action = { source -> + action = { if (source.supportsLatest) { - TextButton(onClick = { onClickLatest(source) }) { + TextButton(onClick = { onClickItem(source, GetRemoteAnime.QUERY_LATEST) }) { Text( text = stringResource(id = R.string.latest), style = LocalTextStyle.current.copy( @@ -174,46 +184,24 @@ fun AnimeSourceItem( } @Composable -fun AnimeSourceIcon( - source: AnimeSource, -) { - val icon = source.icon - val modifier = Modifier - .height(40.dp) - .aspectRatio(1f) - if (icon != null) { - Image( - bitmap = icon, - contentDescription = "", - modifier = modifier, - ) - } else { - Image( - painter = painterResource(id = R.mipmap.ic_local_source), - contentDescription = "", - modifier = modifier, - ) - } -} - -@Composable -fun AnimeSourcePinButton( +private fun AnimeSourcePinButton( isPinned: Boolean, onClick: () -> Unit, ) { val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground + val description = if (isPinned) R.string.action_unpin else R.string.action_pin IconButton(onClick = onClick) { Icon( imageVector = icon, - contentDescription = "", tint = tint, + contentDescription = stringResource(description), ) } } @Composable -fun AnimeSourceOptionsDialog( +private fun AnimeSourceOptionsDialog( source: AnimeSource, onClickPin: () -> Unit, onClickDisable: () -> Unit, @@ -221,7 +209,7 @@ fun AnimeSourceOptionsDialog( ) { AlertDialog( title = { - Text(text = source.nameWithLanguage) + Text(text = source.visualName) }, text = { Column { diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesState.kt new file mode 100644 index 000000000..cf8baae82 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/AnimeSourcesState.kt @@ -0,0 +1,27 @@ +package eu.kanade.presentation.animebrowse + +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.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesPresenter + +@Stable +interface AnimeSourcesState { + var dialog: AnimeSourcesPresenter.Dialog? + val isLoading: Boolean + val items: List + val isEmpty: Boolean +} + +fun AnimeSourcesState(): AnimeSourcesState { + return AnimeSourcesStateImpl() +} + +class AnimeSourcesStateImpl : AnimeSourcesState { + override var dialog: AnimeSourcesPresenter.Dialog? by mutableStateOf(null) + override var isLoading: Boolean by mutableStateOf(true) + override var items: List by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceScreen.kt new file mode 100644 index 000000000..b694771e6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceScreen.kt @@ -0,0 +1,320 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import eu.kanade.data.episode.NoEpisodesException +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.animesource.interactor.GetRemoteAnime +import eu.kanade.domain.library.model.LibraryDisplayMode +import eu.kanade.presentation.animebrowse.components.BrowseAnimeSourceComfortableGrid +import eu.kanade.presentation.animebrowse.components.BrowseAnimeSourceCompactGrid +import eu.kanade.presentation.animebrowse.components.BrowseAnimeSourceList +import eu.kanade.presentation.animebrowse.components.BrowseAnimeSourceToolbar +import eu.kanade.presentation.components.AppStateBanners +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.EmptyScreenAction +import eu.kanade.presentation.components.ExtendedFloatingActionButton +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.LocalAnimeSource +import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourcePresenter +import eu.kanade.tachiyomi.ui.more.MoreController + +@Composable +fun BrowseAnimeSourceScreen( + presenter: BrowseAnimeSourcePresenter, + navigateUp: () -> Unit, + openFilterSheet: () -> Unit, + onAnimeClick: (Anime) -> Unit, + onAnimeLongClick: (Anime) -> Unit, + onWebViewClick: () -> Unit, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, +) { + val columns by presenter.getColumnsPreferenceForCurrentOrientation() + + val animeList = presenter.getAnimeList().collectAsLazyPagingItems() + + val snackbarHostState = remember { SnackbarHostState() } + + val uriHandler = LocalUriHandler.current + + val onHelpClick = { + uriHandler.openUri(LocalAnimeSource.HELP_URL) + } + + Scaffold( + topBar = { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { + BrowseAnimeSourceToolbar( + state = presenter, + source = presenter.source, + displayMode = presenter.displayMode, + onDisplayModeChange = { presenter.displayMode = it }, + navigateUp = navigateUp, + onWebViewClick = onWebViewClick, + onHelpClick = onHelpClick, + onSearch = { presenter.search(it) }, + ) + + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = presenter.currentFilter == BrowseAnimeSourcePresenter.AnimeFilter.Popular, + onClick = { + presenter.reset() + presenter.search(GetRemoteAnime.QUERY_POPULAR) + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Favorite, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.popular)) + }, + ) + if (presenter.source?.supportsLatest == true) { + FilterChip( + selected = presenter.currentFilter == BrowseAnimeSourcePresenter.AnimeFilter.Latest, + onClick = { + presenter.reset() + presenter.search(GetRemoteAnime.QUERY_LATEST) + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.latest)) + }, + ) + } + if (presenter.filters.isNotEmpty()) { + FilterChip( + selected = presenter.currentFilter is BrowseAnimeSourcePresenter.AnimeFilter.UserInput, + onClick = openFilterSheet, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.action_filter)) + }, + ) + } + } + + Divider() + + AppStateBanners(downloadedOnlyMode, incognitoMode) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { paddingValues -> + BrowseAnimeSourceContent( + state = presenter, + animeList = animeList, + getAnimeState = { presenter.getAnime(it) }, + columns = columns, + displayMode = presenter.displayMode, + snackbarHostState = snackbarHostState, + contentPadding = paddingValues, + onWebViewClick = onWebViewClick, + onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, + onLocalAnimeSourceHelpClick = onHelpClick, + onAnimeClick = onAnimeClick, + onAnimeLongClick = onAnimeLongClick, + ) + } +} + +@Composable +fun BrowseAnimeSourceFloatingActionButton( + modifier: Modifier = Modifier.navigationBarsPadding(), + isVisible: Boolean, + onFabClick: () -> Unit, +) { + AnimatedVisibility(visible = isVisible) { + ExtendedFloatingActionButton( + modifier = modifier, + text = { Text(text = stringResource(R.string.action_filter)) }, + icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") }, + onClick = onFabClick, + ) + } +} + +@Composable +fun BrowseAnimeSourceContent( + state: BrowseAnimeSourceState, + animeList: LazyPagingItems, + getAnimeState: @Composable ((Anime) -> State), + columns: GridCells, + displayMode: LibraryDisplayMode, + snackbarHostState: SnackbarHostState, + contentPadding: PaddingValues, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, + onLocalAnimeSourceHelpClick: () -> Unit, + onAnimeClick: (Anime) -> Unit, + onAnimeLongClick: (Anime) -> Unit, +) { + val context = LocalContext.current + + val errorState = animeList.loadState.refresh.takeIf { it is LoadState.Error } + ?: animeList.loadState.append.takeIf { it is LoadState.Error } + + val getErrorMessage: (LoadState.Error) -> String = { state -> + when { + state.error is NoEpisodesException -> context.getString(R.string.no_results_found) + state.error.message.isNullOrEmpty() -> "" + state.error.message.orEmpty().startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}" + else -> state.error.message.orEmpty() + } + } + + LaunchedEffect(errorState) { + if (animeList.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 -> animeList.refresh() + } + } + } + + if (animeList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) { + EmptyScreen( + message = getErrorMessage(errorState), + actions = if (state.source is LocalAnimeSource) { + listOf( + EmptyScreenAction( + stringResId = R.string.local_source_help_guide, + icon = Icons.Outlined.HelpOutline, + onClick = onLocalAnimeSourceHelpClick, + ), + ) + } else { + listOf( + EmptyScreenAction( + stringResId = R.string.action_retry, + icon = Icons.Outlined.Refresh, + onClick = animeList::refresh, + ), + EmptyScreenAction( + stringResId = R.string.action_open_in_web_view, + icon = Icons.Outlined.Public, + onClick = onWebViewClick, + ), + EmptyScreenAction( + stringResId = R.string.label_help, + icon = Icons.Outlined.HelpOutline, + onClick = onHelpClick, + ), + ) + }, + ) + + return + } + + if (animeList.itemCount == 0 && animeList.loadState.refresh is LoadState.Loading) { + LoadingScreen() + return + } + + when (displayMode) { + LibraryDisplayMode.ComfortableGrid -> { + BrowseAnimeSourceComfortableGrid( + animeList = animeList, + getAnimeState = getAnimeState, + columns = columns, + contentPadding = contentPadding, + onAnimeClick = onAnimeClick, + onAnimeLongClick = onAnimeLongClick, + ) + } + LibraryDisplayMode.List -> { + BrowseAnimeSourceList( + animeList = animeList, + getAnimeState = getAnimeState, + contentPadding = contentPadding, + onAnimeClick = onAnimeClick, + onAnimeLongClick = onAnimeLongClick, + ) + } + else -> { + BrowseAnimeSourceCompactGrid( + animeList = animeList, + getAnimeState = getAnimeState, + columns = columns, + contentPadding = contentPadding, + onAnimeClick = onAnimeClick, + onAnimeLongClick = onAnimeLongClick, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceState.kt new file mode 100644 index 000000000..3bb5e104f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/BrowseAnimeSourceState.kt @@ -0,0 +1,42 @@ +package eu.kanade.presentation.animebrowse + +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.animesource.AnimeCatalogueSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourcePresenter +import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourcePresenter.AnimeFilter +import eu.kanade.tachiyomi.ui.browse.animesource.browse.toItems + +@Stable +interface BrowseAnimeSourceState { + val source: AnimeCatalogueSource? + var searchQuery: String? + val currentFilter: AnimeFilter + val isUserQuery: Boolean + val filters: AnimeFilterList + val filterItems: List> + var dialog: BrowseAnimeSourcePresenter.Dialog? +} + +fun BrowseAnimeSourceState(initialQuery: String?): BrowseAnimeSourceState { + return when (val filter = AnimeFilter.valueOf(initialQuery ?: "")) { + AnimeFilter.Latest, AnimeFilter.Popular -> BrowseAnimeSourceStateImpl(initialCurrentFilter = filter) + is AnimeFilter.UserInput -> BrowseAnimeSourceStateImpl(initialQuery = initialQuery, initialCurrentFilter = filter) + } +} + +class BrowseAnimeSourceStateImpl(initialQuery: String? = null, initialCurrentFilter: AnimeFilter) : + BrowseAnimeSourceState { + override var source: AnimeCatalogueSource? by mutableStateOf(null) + override var searchQuery: String? by mutableStateOf(initialQuery) + override var currentFilter: AnimeFilter by mutableStateOf(initialCurrentFilter) + override val isUserQuery: Boolean by derivedStateOf { currentFilter is AnimeFilter.UserInput && currentFilter.query.isNotEmpty() } + override var filters: AnimeFilterList by mutableStateOf(AnimeFilterList()) + override val filterItems: List> by derivedStateOf { filters.toItems() } + override var dialog: BrowseAnimeSourcePresenter.Dialog? by mutableStateOf(null) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeScreen.kt new file mode 100644 index 000000000..2812ce57d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeScreen.kt @@ -0,0 +1,101 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import eu.kanade.domain.anime.model.Anime +import eu.kanade.presentation.anime.components.BaseAnimeListItem +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrateAnimePresenter +import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrateAnimePresenter.Event +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun MigrateAnimeScreen( + navigateUp: () -> Unit, + title: String?, + presenter: MigrateAnimePresenter, + onClickItem: (Anime) -> Unit, + onClickCover: (Anime) -> Unit, +) { + val context = LocalContext.current + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = title, + navigateUp = navigateUp, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.empty_screen, + modifier = Modifier.padding(contentPadding), + ) + else -> { + MigrateAnimeContent( + contentPadding = contentPadding, + state = presenter, + onClickItem = onClickItem, + onClickCover = onClickCover, + ) + } + } + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + Event.FailedFetchingFavorites -> { + context.toast(R.string.internal_error) + } + } + } + } +} + +@Composable +private fun MigrateAnimeContent( + contentPadding: PaddingValues, + state: MigrateAnimeState, + onClickItem: (Anime) -> Unit, + onClickCover: (Anime) -> Unit, +) { + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + items(state.items) { anime -> + MigrateAnimeItem( + anime = anime, + onClickItem = onClickItem, + onClickCover = onClickCover, + ) + } + } +} + +@Composable +private fun MigrateAnimeItem( + modifier: Modifier = Modifier, + anime: Anime, + onClickItem: (Anime) -> Unit, + onClickCover: (Anime) -> Unit, +) { + BaseAnimeListItem( + modifier = modifier, + anime = anime, + onClickItem = { onClickItem(anime) }, + onClickCover = { onClickCover(anime) }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceScreen.kt new file mode 100644 index 000000000..07ee53e9a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceScreen.kt @@ -0,0 +1,191 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.Numbers +import androidx.compose.material.icons.outlined.SortByAlpha +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.domain.source.interactor.SetMigrateSorting +import eu.kanade.presentation.animebrowse.components.AnimeSourceIcon +import eu.kanade.presentation.animebrowse.components.BaseAnimeSourceItem +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.BadgeGroup +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.components.Scroller.STICKY_HEADER_KEY_PREFIX +import eu.kanade.presentation.theme.header +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.secondaryItemAlpha +import eu.kanade.presentation.util.topPaddingValues +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrationAnimeSourcesPresenter +import eu.kanade.tachiyomi.util.system.copyToClipboard + +@Composable +fun MigrateAnimeSourceScreen( + presenter: MigrationAnimeSourcesPresenter, + contentPadding: PaddingValues, + onClickItem: (AnimeSource) -> Unit, +) { + val context = LocalContext.current + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.information_empty_library, + modifier = Modifier.padding(contentPadding), + ) + else -> + MigrateAnimeSourceList( + list = presenter.items, + contentPadding = contentPadding, + onClickItem = onClickItem, + onLongClickItem = { source -> + val sourceId = source.id.toString() + context.copyToClipboard(sourceId, sourceId) + }, + sortingMode = presenter.sortingMode, + onToggleSortingMode = { presenter.toggleSortingMode() }, + sortingDirection = presenter.sortingDirection, + onToggleSortingDirection = { presenter.toggleSortingDirection() }, + ) + } +} + +@Composable +private fun MigrateAnimeSourceList( + list: List>, + contentPadding: PaddingValues, + onClickItem: (AnimeSource) -> Unit, + onLongClickItem: (AnimeSource) -> Unit, + sortingMode: SetMigrateSorting.Mode, + onToggleSortingMode: () -> Unit, + sortingDirection: SetMigrateSorting.Direction, + onToggleSortingDirection: () -> Unit, +) { + ScrollbarLazyColumn( + contentPadding = contentPadding + topPaddingValues, + ) { + stickyHeader(key = STICKY_HEADER_KEY_PREFIX) { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .padding(start = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.migration_selection_prompt), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.header, + ) + + IconButton(onClick = onToggleSortingMode) { + when (sortingMode) { + SetMigrateSorting.Mode.ALPHABETICAL -> Icon(Icons.Outlined.SortByAlpha, contentDescription = stringResource(R.string.action_sort_alpha)) + SetMigrateSorting.Mode.TOTAL -> Icon(Icons.Outlined.Numbers, contentDescription = stringResource(R.string.action_sort_count)) + } + } + IconButton(onClick = onToggleSortingDirection) { + when (sortingDirection) { + SetMigrateSorting.Direction.ASCENDING -> Icon(Icons.Outlined.ArrowUpward, contentDescription = stringResource(R.string.action_asc)) + SetMigrateSorting.Direction.DESCENDING -> Icon(Icons.Outlined.ArrowDownward, contentDescription = stringResource(R.string.action_desc)) + } + } + } + } + + items( + items = list, + key = { (source, _) -> "migrate-${source.id}" }, + ) { (source, count) -> + MigrateAnimeSourceItem( + modifier = Modifier.animateItemPlacement(), + source = source, + count = count, + onClickItem = { onClickItem(source) }, + onLongClickItem = { onLongClickItem(source) }, + ) + } + } +} + +@Composable +private fun MigrateAnimeSourceItem( + modifier: Modifier = Modifier, + source: AnimeSource, + count: Long, + onClickItem: () -> Unit, + onLongClickItem: () -> Unit, +) { + BaseAnimeSourceItem( + modifier = modifier, + source = source, + showLanguageInContent = source.lang != "", + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + icon = { AnimeSourceIcon(source = source) }, + action = { + BadgeGroup { + Badge(text = "$count") + } + }, + content = { _, sourceLangString -> + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .weight(1f), + ) { + Text( + text = source.name.ifBlank { source.id.toString() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (sourceLangString != null) { + Text( + modifier = Modifier.secondaryItemAlpha(), + text = sourceLangString, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + if (source.isStub) { + Text( + modifier = Modifier.secondaryItemAlpha(), + text = stringResource(R.string.not_installed), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceState.kt new file mode 100644 index 000000000..28c9fa781 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeSourceState.kt @@ -0,0 +1,28 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.domain.source.interactor.SetMigrateSorting + +interface MigrateAnimeSourceState { + val isLoading: Boolean + val items: List> + val isEmpty: Boolean + val sortingMode: SetMigrateSorting.Mode + val sortingDirection: SetMigrateSorting.Direction +} + +fun MigrateAnimeSourceState(): MigrateAnimeSourceState { + return MigrateAnimeSourceStateImpl() +} + +class MigrateAnimeSourceStateImpl : MigrateAnimeSourceState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List> by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } + override var sortingMode: SetMigrateSorting.Mode by mutableStateOf(SetMigrateSorting.Mode.ALPHABETICAL) + override var sortingDirection: SetMigrateSorting.Direction by mutableStateOf(SetMigrateSorting.Direction.ASCENDING) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeState.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeState.kt new file mode 100644 index 000000000..f0610afd8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/MigrateAnimeState.kt @@ -0,0 +1,23 @@ +package eu.kanade.presentation.animebrowse + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.domain.anime.model.Anime + +interface MigrateAnimeState { + val isLoading: Boolean + val items: List + val isEmpty: Boolean +} + +fun MigrationAnimeState(): MigrateAnimeState { + return MigrateAnimeStateImpl() +} + +class MigrateAnimeStateImpl : MigrateAnimeState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BaseAnimeSourceItem.kt similarity index 69% rename from app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt rename to app/src/main/java/eu/kanade/presentation/animebrowse/components/BaseAnimeSourceItem.kt index f9aef7ccc..56f56b6db 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BaseAnimeSourceItem.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.browse.components +package eu.kanade.presentation.animebrowse.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope @@ -7,9 +7,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.tachiyomi.util.system.LocaleHelper @Composable @@ -21,15 +24,16 @@ fun BaseAnimeSourceItem( onLongClickItem: () -> Unit = {}, icon: @Composable RowScope.(AnimeSource) -> Unit = defaultIcon, action: @Composable RowScope.(AnimeSource) -> Unit = {}, - content: @Composable RowScope.(AnimeSource, Boolean) -> Unit = defaultContent, + content: @Composable RowScope.(AnimeSource, String?) -> Unit = defaultContent, ) { + val sourceLangString = LocaleHelper.getSourceDisplayName(source.lang, LocalContext.current).takeIf { showLanguageInContent } BaseBrowseItem( modifier = modifier, onClickItem = onClickItem, onLongClickItem = onLongClickItem, icon = { icon.invoke(this, source) }, action = { action.invoke(this, source) }, - content = { content.invoke(this, source, showLanguageInContent) }, + content = { content.invoke(this, source, sourceLangString) }, ) } @@ -37,21 +41,22 @@ private val defaultIcon: @Composable RowScope.(AnimeSource) -> Unit = { source - AnimeSourceIcon(source = source) } -private val defaultContent: @Composable RowScope.(AnimeSource, Boolean) -> Unit = { source, showLanguageInContent -> +private val defaultContent: @Composable RowScope.(AnimeSource, String?) -> Unit = { source, sourceLangString -> Column( modifier = Modifier .padding(horizontal = horizontalPadding) .weight(1f), ) { Text( - text = source.name.ifBlank { source.id.toString() }, + text = source.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) - if (showLanguageInContent) { + if (sourceLangString != null) { Text( - text = LocaleHelper.getDisplayName(source.lang), + modifier = Modifier.secondaryItemAlpha(), + text = sourceLangString, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeIcons.kt similarity index 62% rename from app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt rename to app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeIcons.kt index 643a9bc34..6b5849d62 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeIcons.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.browse.components +package eu.kanade.presentation.animebrowse.components import android.content.pm.PackageManager import android.util.DisplayMetrics @@ -6,7 +6,10 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -14,6 +17,7 @@ import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.ColorPainter @@ -26,7 +30,7 @@ import coil.compose.AsyncImage import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import eu.kanade.tachiyomi.util.lang.withIOContext private val defaultModifier = Modifier @@ -40,23 +44,34 @@ fun AnimeSourceIcon( ) { val icon = source.icon - if (icon != null) { - Image( - bitmap = icon, - contentDescription = "", - modifier = modifier.then(defaultModifier), - ) - } else { - Image( - painter = painterResource(id = R.mipmap.ic_local_source), - contentDescription = "", - modifier = modifier.then(defaultModifier), - ) + when { + source.isStub && icon == null -> { + Image( + imageVector = Icons.Filled.Warning, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), + modifier = modifier.then(defaultModifier), + ) + } + icon != null -> { + Image( + bitmap = icon, + contentDescription = null, + modifier = modifier.then(defaultModifier), + ) + } + else -> { + Image( + painter = painterResource(id = R.mipmap.ic_local_source), + contentDescription = null, + modifier = modifier.then(defaultModifier), + ) + } } } @Composable -fun ExtensionIcon( +fun AnimeExtensionIcon( extension: AnimeExtension, modifier: Modifier = Modifier, density: Int = DisplayMetrics.DENSITY_DEFAULT, @@ -65,33 +80,33 @@ fun ExtensionIcon( is AnimeExtension.Available -> { AsyncImage( model = extension.iconUrl, - contentDescription = "", + contentDescription = null, placeholder = ColorPainter(Color(0x1F888888)), error = rememberResourceBitmapPainter(id = R.drawable.cover_error), modifier = modifier - .clip(RoundedCornerShape(4.dp)) - .then(defaultModifier), + .clip(MaterialTheme.shapes.extraSmall), ) } is AnimeExtension.Installed -> { val icon by extension.getIcon(density) when (icon) { - Result.Error -> Image( + is Result.Error -> Image( bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source), - contentDescription = "", - modifier = modifier.then(defaultModifier), + contentDescription = null, + modifier = modifier, ) - Result.Loading -> Box(modifier = modifier.then(defaultModifier)) + is Result.Loading -> Box(modifier = modifier) is Result.Success -> Image( bitmap = (icon as Result.Success).value, - contentDescription = "", - modifier = modifier.then(defaultModifier), + contentDescription = null, + modifier = modifier, ) } } is AnimeExtension.Untrusted -> Image( - bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source), - contentDescription = "", + imageVector = Icons.Filled.Dangerous, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), modifier = modifier.then(defaultModifier), ) } @@ -116,3 +131,9 @@ private fun AnimeExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT } } } + +sealed class Result { + object Loading : Result() + object Error : Result() + data class Success(val value: T) : Result() +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceComfortableGrid.kt new file mode 100644 index 000000000..5fde5de4f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceComfortableGrid.kt @@ -0,0 +1,87 @@ +package eu.kanade.presentation.animebrowse.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.browse.components.BrowseSourceLoadingItem +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.CommonMangaItemDefaults +import eu.kanade.presentation.components.MangaComfortableGridItem +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseAnimeSourceComfortableGrid( + animeList: LazyPagingItems, + getAnimeState: @Composable ((Anime) -> State), + columns: GridCells, + contentPadding: PaddingValues, + onAnimeClick: (Anime) -> Unit, + onAnimeLongClick: (Anime) -> Unit, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = contentPadding + PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer), + horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer), + ) { + if (animeList.loadState.prepend is LoadState.Loading) { + item(span = { GridItemSpan(maxLineSpan) }) { + BrowseSourceLoadingItem() + } + } + + items(animeList.itemCount) { index -> + val initialAnime = animeList[index] ?: return@items + val anime by getAnimeState(initialAnime) + BrowseAnimeSourceComfortableGridItem( + anime = anime, + onClick = { onAnimeClick(anime) }, + onLongClick = { onAnimeLongClick(anime) }, + ) + } + + if (animeList.loadState.refresh is LoadState.Loading || animeList.loadState.append is LoadState.Loading) { + item(span = { GridItemSpan(maxLineSpan) }) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +fun BrowseAnimeSourceComfortableGridItem( + anime: Anime, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + MangaComfortableGridItem( + title = anime.title, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + coverAlpha = if (anime.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, + coverBadgeStart = { + if (anime.favorite) { + Badge(text = stringResource(R.string.in_library)) + } + }, + onLongClick = onLongClick, + onClick = onClick, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceCompactGrid.kt new file mode 100644 index 000000000..f1cfc63d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceCompactGrid.kt @@ -0,0 +1,87 @@ +package eu.kanade.presentation.animebrowse.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.browse.components.BrowseSourceLoadingItem +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.CommonMangaItemDefaults +import eu.kanade.presentation.components.MangaCompactGridItem +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseAnimeSourceCompactGrid( + animeList: LazyPagingItems, + getAnimeState: @Composable ((Anime) -> State), + columns: GridCells, + contentPadding: PaddingValues, + onAnimeClick: (Anime) -> Unit, + onAnimeLongClick: (Anime) -> Unit, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = contentPadding + PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer), + horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer), + ) { + if (animeList.loadState.prepend is LoadState.Loading) { + item(span = { GridItemSpan(maxLineSpan) }) { + BrowseSourceLoadingItem() + } + } + + items(animeList.itemCount) { index -> + val initialAnime = animeList[index] ?: return@items + val anime by getAnimeState(initialAnime) + BrowseAnimeSourceCompactGridItem( + anime = anime, + onClick = { onAnimeClick(anime) }, + onLongClick = { onAnimeLongClick(anime) }, + ) + } + + if (animeList.loadState.refresh is LoadState.Loading || animeList.loadState.append is LoadState.Loading) { + item(span = { GridItemSpan(maxLineSpan) }) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +private fun BrowseAnimeSourceCompactGridItem( + anime: Anime, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + MangaCompactGridItem( + title = anime.title, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + coverAlpha = if (anime.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, + coverBadgeStart = { + if (anime.favorite) { + Badge(text = stringResource(R.string.in_library)) + } + }, + onLongClick = onLongClick, + onClick = onClick, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceDialogs.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceDialogs.kt new file mode 100644 index 000000000..484a3288b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceDialogs.kt @@ -0,0 +1,41 @@ +package eu.kanade.presentation.animebrowse.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.domain.anime.model.Anime +import eu.kanade.tachiyomi.R + +@Composable +fun RemoveAnimeDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + animeToRemove: Anime, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirm() + }, + ) { + Text(text = stringResource(R.string.action_remove)) + } + }, + title = { + Text(text = stringResource(R.string.are_you_sure)) + }, + text = { + Text(text = stringResource(R.string.remove_manga, animeToRemove.title)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceList.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceList.kt new file mode 100644 index 000000000..6bc7f6ad6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceList.kt @@ -0,0 +1,81 @@ +package eu.kanade.presentation.animebrowse.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.items +import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.browse.components.BrowseSourceLoadingItem +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.CommonMangaItemDefaults +import eu.kanade.presentation.components.LazyColumn +import eu.kanade.presentation.components.MangaListItem +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseAnimeSourceList( + animeList: LazyPagingItems, + getAnimeState: @Composable ((Anime) -> State), + contentPadding: PaddingValues, + onAnimeClick: (Anime) -> Unit, + onAnimeLongClick: (Anime) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding + PaddingValues(vertical = 8.dp), + ) { + item { + if (animeList.loadState.prepend is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + + items(animeList) { initialAnime -> + initialAnime ?: return@items + val anime by getAnimeState(initialAnime) + BrowseAnimeSourceListItem( + anime = anime, + onClick = { onAnimeClick(anime) }, + onLongClick = { onAnimeLongClick(anime) }, + ) + } + + item { + if (animeList.loadState.refresh is LoadState.Loading || animeList.loadState.append is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +fun BrowseAnimeSourceListItem( + anime: Anime, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + MangaListItem( + title = anime.title, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + coverAlpha = if (anime.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, + badge = { + if (anime.favorite) { + Badge(text = stringResource(R.string.in_library)) + } + }, + onLongClick = onLongClick, + onClick = onClick, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceToolbar.kt b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceToolbar.kt new file mode 100644 index 000000000..bf2c7472d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animebrowse/components/BrowseAnimeSourceToolbar.kt @@ -0,0 +1,175 @@ +package eu.kanade.presentation.animebrowse.components + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.ViewModule +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.Text +import androidx.compose.material3.TopAppBarScrollBehavior +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.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import eu.kanade.domain.library.model.LibraryDisplayMode +import eu.kanade.presentation.animebrowse.BrowseAnimeSourceState +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.presentation.components.RadioMenuItem +import eu.kanade.presentation.components.SearchToolbar +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource +import eu.kanade.tachiyomi.animesource.LocalAnimeSource + +@Composable +fun BrowseAnimeSourceToolbar( + state: BrowseAnimeSourceState, + source: AnimeCatalogueSource?, + displayMode: LibraryDisplayMode, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + navigateUp: () -> Unit, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, + onSearch: (String) -> Unit, + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + if (state.searchQuery == null) { + BrowseAnimeSourceRegularToolbar( + title = if (state.isUserQuery) state.currentFilter.query else source?.name.orEmpty(), + isLocalSource = source is LocalAnimeSource, + displayMode = displayMode, + onDisplayModeChange = onDisplayModeChange, + navigateUp = navigateUp, + onSearchClick = { state.searchQuery = if (state.isUserQuery) state.currentFilter.query else "" }, + onWebViewClick = onWebViewClick, + onHelpClick = onHelpClick, + scrollBehavior = scrollBehavior, + ) + } else { + val cancelSearch = { state.searchQuery = null } + BrowseAnimeSourceSearchToolbar( + searchQuery = state.searchQuery!!, + onSearchQueryChanged = { state.searchQuery = it }, + placeholderText = stringResource(R.string.action_search_hint), + navigateUp = cancelSearch, + onResetClick = { state.searchQuery = "" }, + onSearchClick = { + onSearch(it) + cancelSearch() + }, + scrollBehavior = scrollBehavior, + ) + } +} + +@Composable +fun BrowseAnimeSourceRegularToolbar( + title: String, + isLocalSource: Boolean, + displayMode: LibraryDisplayMode, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + navigateUp: () -> Unit, + onSearchClick: () -> Unit, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, +) { + AppBar( + navigateUp = navigateUp, + title = title, + actions = { + var selectingDisplayMode by remember { mutableStateOf(false) } + AppBarActions( + actions = listOf( + AppBar.Action( + title = stringResource(R.string.action_search), + icon = Icons.Outlined.Search, + onClick = onSearchClick, + ), + AppBar.Action( + title = stringResource(R.string.action_display_mode), + icon = if (displayMode == LibraryDisplayMode.List) Icons.Filled.ViewList else Icons.Filled.ViewModule, + onClick = { selectingDisplayMode = true }, + ), + if (isLocalSource) { + AppBar.Action( + title = stringResource(R.string.label_help), + icon = Icons.Outlined.Help, + onClick = onHelpClick, + ) + } else { + AppBar.Action( + title = stringResource(R.string.action_web_view), + icon = Icons.Outlined.Public, + onClick = onWebViewClick, + ) + }, + ), + ) + DropdownMenu( + expanded = selectingDisplayMode, + onDismissRequest = { selectingDisplayMode = false }, + ) { + RadioMenuItem( + text = { Text(text = stringResource(R.string.action_display_comfortable_grid)) }, + isChecked = displayMode == LibraryDisplayMode.ComfortableGrid, + ) { + onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) + } + RadioMenuItem( + text = { Text(text = stringResource(R.string.action_display_grid)) }, + isChecked = displayMode == LibraryDisplayMode.CompactGrid, + ) { + onDisplayModeChange(LibraryDisplayMode.CompactGrid) + } + RadioMenuItem( + text = { Text(text = stringResource(R.string.action_display_list)) }, + isChecked = displayMode == LibraryDisplayMode.List, + ) { + onDisplayModeChange(LibraryDisplayMode.List) + } + } + }, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +fun BrowseAnimeSourceSearchToolbar( + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + placeholderText: String?, + navigateUp: () -> Unit, + onResetClick: () -> Unit, + onSearchClick: (String) -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + SearchToolbar( + searchQuery = searchQuery, + onChangeSearchQuery = onSearchQueryChanged, + placeholderText = placeholderText, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearchClick(searchQuery) + focusManager.clearFocus() + keyboardController?.hide() + }, + ), + onClickCloseSearch = navigateUp, + onClickResetSearch = onResetClick, + scrollBehavior = scrollBehavior, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animehistory/AnimeHistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/animehistory/AnimeHistoryScreen.kt new file mode 100644 index 000000000..735cf6603 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animehistory/AnimeHistoryScreen.kt @@ -0,0 +1,117 @@ +package eu.kanade.presentation.animehistory + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations +import eu.kanade.presentation.animehistory.components.AnimeHistoryContent +import eu.kanade.presentation.animehistory.components.AnimeHistoryToolbar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.history.components.HistoryDeleteAllDialog +import eu.kanade.presentation.history.components.HistoryDeleteDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.recent.HistoryTabsController.Companion.isCurrentHistoryTabManga +import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryPresenter +import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryPresenter.Dialog +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView +import kotlinx.coroutines.flow.collectLatest +import uy.kohesive.injekt.api.get +import java.util.Date + +@Composable +fun AnimeHistoryScreen( + presenter: AnimeHistoryPresenter, + onClickCover: (AnimeHistoryWithRelations) -> Unit, + onClickResume: (AnimeHistoryWithRelations) -> Unit, +) { + val context = LocalContext.current + + isCurrentHistoryTabManga = false + + Scaffold( + topBar = { scrollBehavior -> + AnimeHistoryToolbar( + state = presenter, + incognitoMode = presenter.isIncognitoMode, + downloadedOnlyMode = presenter.isDownloadOnly, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + val items by presenter.getAnimeHistory().collectAsState(initial = null) + val contentPaddingWithNavBar = + TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding) + items.let { + if (it == null) { + LoadingScreen() + } else if (it.isEmpty()) { + EmptyScreen( + textResource = R.string.information_no_recent_manga, + modifier = Modifier.padding(contentPaddingWithNavBar), + ) + } else { + AnimeHistoryContent( + history = it, + contentPadding = contentPaddingWithNavBar, + onClickCover = onClickCover, + onClickResume = onClickResume, + onClickDelete = { item -> presenter.dialog = Dialog.Delete(item) }, + ) + } + } + LaunchedEffect(items) { + if (items != null) { + (presenter.context as? MainActivity)?.ready = true + } + } + } + + val onDismissRequest = { presenter.dialog = null } + when (val dialog = presenter.dialog) { + is Dialog.Delete -> { + HistoryDeleteDialog( + onDismissRequest = onDismissRequest, + onDelete = { all -> + if (all) { + presenter.removeAllFromHistory(dialog.history.animeId) + } else { + presenter.removeFromHistory(dialog.history) + } + }, + ) + } + is Dialog.DeleteAll -> { + HistoryDeleteAllDialog( + onDismissRequest = onDismissRequest, + onDelete = { + presenter.deleteAllAnimeHistory() + }, + ) + } + null -> {} + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + AnimeHistoryPresenter.Event.InternalError -> context.toast(R.string.internal_error) + AnimeHistoryPresenter.Event.NoNextEpisodeFound -> context.toast(R.string.no_next_episode) + is AnimeHistoryPresenter.Event.OpenEpisode -> { + presenter.openEpisode(event.episode) + } + } + } + } +} + +sealed class AnimeHistoryUiModel { + data class Header(val date: Date) : AnimeHistoryUiModel() + data class Item(val item: AnimeHistoryWithRelations) : AnimeHistoryUiModel() +} diff --git a/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt deleted file mode 100644 index 06b6c1ad3..000000000 --- a/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt +++ /dev/null @@ -1,222 +0,0 @@ -package eu.kanade.presentation.animehistory - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush.Companion.linearGradient -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.paging.LoadState -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.items -import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.ScrollbarLazyColumn -import eu.kanade.presentation.history.components.AnimeHistoryItem -import eu.kanade.presentation.history.components.HistoryHeader -import eu.kanade.presentation.history.components.HistoryItemShimmer -import eu.kanade.presentation.util.plus -import eu.kanade.presentation.util.shimmerGradient -import eu.kanade.presentation.util.topPaddingValues -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryPresenter -import eu.kanade.tachiyomi.ui.recent.animehistory.HistoryState -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.text.DateFormat -import java.util.Date - -@Composable -fun AnimeHistoryScreen( - nestedScrollInterop: NestedScrollConnection, - presenter: AnimeHistoryPresenter, - onClickCover: (AnimeHistoryWithRelations) -> Unit, - onClickResume: (AnimeHistoryWithRelations) -> Unit, - onClickDelete: (AnimeHistoryWithRelations, Boolean) -> Unit, -) { - val state by presenter.state.collectAsState() - when (state) { - is HistoryState.Loading -> LoadingScreen() - is HistoryState.Error -> Text(text = (state as HistoryState.Error).error.message!!) - is HistoryState.Success -> - HistoryContent( - nestedScroll = nestedScrollInterop, - history = (state as HistoryState.Success).uiModels.collectAsLazyPagingItems(), - onClickCover = onClickCover, - onClickResume = onClickResume, - onClickDelete = onClickDelete, - ) - } -} - -@Composable -fun HistoryContent( - history: LazyPagingItems, - onClickCover: (AnimeHistoryWithRelations) -> Unit, - onClickResume: (AnimeHistoryWithRelations) -> Unit, - onClickDelete: (AnimeHistoryWithRelations, Boolean) -> Unit, - preferences: PreferencesHelper = Injekt.get(), - nestedScroll: NestedScrollConnection, -) { - if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) { - EmptyScreen(textResource = R.string.information_no_recent_anime) - return - } - - val relativeTime: Int = remember { preferences.relativeTime().get() } - val dateFormat: DateFormat = remember { preferences.dateFormat() } - - var removeState by remember { mutableStateOf(null) } - - val scrollState = rememberLazyListState() - - ScrollbarLazyColumn( - modifier = Modifier - .nestedScroll(nestedScroll), - contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, - state = scrollState, - ) { - items(history) { item -> - when (item) { - is HistoryUiModel.Header -> { - HistoryHeader( - modifier = Modifier - .animateItemPlacement(), - date = item.date, - relativeTime = relativeTime, - dateFormat = dateFormat, - ) - } - is HistoryUiModel.Item -> { - val value = item.item - AnimeHistoryItem( - modifier = Modifier.animateItemPlacement(), - history = value, - onClickCover = { onClickCover(value) }, - onClickResume = { onClickResume(value) }, - onClickDelete = { removeState = value }, - ) - } - null -> { - val transition = rememberInfiniteTransition() - val translateAnimation = transition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1000, - easing = LinearEasing, - ), - ), - ) - - val brush = remember { - linearGradient( - colors = shimmerGradient, - start = Offset(0f, 0f), - end = Offset( - x = translateAnimation.value, - y = 00f, - ), - ) - } - HistoryItemShimmer(brush = brush) - } - } - } - } - - if (removeState != null) { - RemoveHistoryDialog( - onPositive = { all -> - onClickDelete(removeState!!, all) - removeState = null - }, - onNegative = { removeState = null }, - ) - } -} - -@Composable -fun RemoveHistoryDialog( - onPositive: (Boolean) -> Unit, - onNegative: () -> Unit, -) { - var removeEverything by remember { mutableStateOf(false) } - - AlertDialog( - title = { - Text(text = stringResource(R.string.action_remove)) - }, - text = { - Column { - Text(text = stringResource(R.string.dialog_with_checkbox_remove_description_anime)) - Row( - modifier = Modifier - .padding(top = 16.dp) - .toggleable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - value = removeEverything, - onValueChange = { removeEverything = it }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = removeEverything, - onCheckedChange = null, - ) - Text( - modifier = Modifier.padding(start = 4.dp), - text = stringResource(R.string.dialog_with_checkbox_reset_anime), - ) - } - } - }, - onDismissRequest = onNegative, - confirmButton = { - TextButton(onClick = { onPositive(removeEverything) }) { - Text(text = stringResource(R.string.action_remove)) - } - }, - dismissButton = { - TextButton(onClick = onNegative) { - Text(text = stringResource(R.string.action_cancel)) - } - }, - ) -} - -sealed class HistoryUiModel { - data class Header(val date: Date) : HistoryUiModel() - data class Item(val item: AnimeHistoryWithRelations) : HistoryUiModel() -} diff --git a/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryContent.kt b/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryContent.kt new file mode 100644 index 000000000..fa812b304 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryContent.kt @@ -0,0 +1,65 @@ +package eu.kanade.presentation.animehistory.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.animehistory.AnimeHistoryUiModel +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.RelativeDateHeader +import eu.kanade.presentation.history.components.AnimeHistoryItem +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.text.DateFormat + +@Composable +fun AnimeHistoryContent( + history: List, + contentPadding: PaddingValues, + onClickCover: (AnimeHistoryWithRelations) -> Unit, + onClickResume: (AnimeHistoryWithRelations) -> Unit, + onClickDelete: (AnimeHistoryWithRelations) -> Unit, + preferences: UiPreferences = Injekt.get(), +) { + val relativeTime: Int = remember { preferences.relativeTime().get() } + val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } + + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + items( + items = history, + key = { "history-${it.hashCode()}" }, + contentType = { + when (it) { + is AnimeHistoryUiModel.Header -> "header" + is AnimeHistoryUiModel.Item -> "item" + } + }, + ) { item -> + when (item) { + is AnimeHistoryUiModel.Header -> { + RelativeDateHeader( + modifier = Modifier.animateItemPlacement(), + date = item.date, + relativeTime = relativeTime, + dateFormat = dateFormat, + ) + } + is AnimeHistoryUiModel.Item -> { + val value = item.item + AnimeHistoryItem( + modifier = Modifier.animateItemPlacement(), + history = value, + onClickCover = { onClickCover(value) }, + onClickResume = { onClickResume(value) }, + onClickDelete = { onClickDelete(value) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryToolbar.kt b/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryToolbar.kt new file mode 100644 index 000000000..00ba69197 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animehistory/components/AnimeHistoryToolbar.kt @@ -0,0 +1,84 @@ +package eu.kanade.presentation.animehistory.components + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.SearchToolbar +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryPresenter +import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryState + +@Composable +fun AnimeHistoryToolbar( + state: AnimeHistoryState, + scrollBehavior: TopAppBarScrollBehavior, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + if (state.searchQuery == null) { + HistoryRegularToolbar( + onClickSearch = { state.searchQuery = "" }, + onClickDelete = { state.dialog = AnimeHistoryPresenter.Dialog.DeleteAll }, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + scrollBehavior = scrollBehavior, + ) + } else { + SearchToolbar( + searchQuery = state.searchQuery!!, + onChangeSearchQuery = { state.searchQuery = it }, + placeholderText = stringResource(R.string.action_search_hint), + onClickCloseSearch = { state.searchQuery = null }, + onClickResetSearch = { state.searchQuery = "" }, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + keyboardController?.hide() + }, + ), + ) + } +} + +@Composable +fun HistoryRegularToolbar( + onClickSearch: () -> Unit, + onClickDelete: () -> Unit, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, + scrollBehavior: TopAppBarScrollBehavior, +) { + AppBar( + title = stringResource(R.string.history), + actions = { + IconButton(onClick = onClickSearch) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search)) + } + IconButton(onClick = onClickDelete) { + Icon(Icons.Outlined.DeleteSweep, contentDescription = stringResource(R.string.pref_clear_history)) + } + }, + downloadedOnlyMode = downloadedOnlyMode, + incognitoMode = incognitoMode, + scrollBehavior = scrollBehavior, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/AnimelibScreen.kt b/app/src/main/java/eu/kanade/presentation/animelib/AnimelibScreen.kt new file mode 100644 index 000000000..58d61f409 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/AnimelibScreen.kt @@ -0,0 +1,115 @@ +package eu.kanade.presentation.animelib + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import eu.kanade.domain.anime.model.isLocal +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.library.model.display +import eu.kanade.presentation.animelib.components.AnimelibContent +import eu.kanade.presentation.animelib.components.AnimelibToolbar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.EmptyScreenAction +import eu.kanade.presentation.components.LibraryBottomActionMenu +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.animelib.AnimelibPresenter +import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView + +@Composable +fun AnimelibScreen( + presenter: AnimelibPresenter, + onAnimeClicked: (Long) -> Unit, + onGlobalSearchClicked: () -> Unit, + onChangeCategoryClicked: () -> Unit, + onMarkAsSeenClicked: () -> Unit, + onMarkAsUnseenClicked: () -> Unit, + onDownloadClicked: () -> Unit, + onDeleteClicked: () -> Unit, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: (Category?) -> Boolean, +) { + Scaffold( + topBar = { scrollBehavior -> + val title by presenter.getToolbarTitle() + val tabVisible = presenter.tabVisibility && presenter.categories.size > 1 + AnimelibToolbar( + state = presenter, + title = title, + incognitoMode = !tabVisible && presenter.isIncognitoMode, + downloadedOnlyMode = !tabVisible && presenter.isDownloadOnly, + onClickUnselectAll = onClickUnselectAll, + onClickSelectAll = onClickSelectAll, + onClickInvertSelection = onClickInvertSelection, + onClickFilter = onClickFilter, + onClickRefresh = { onClickRefresh(null) }, + scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab + ) + }, + bottomBar = { + LibraryBottomActionMenu( + visible = presenter.selectionMode, + onChangeCategoryClicked = onChangeCategoryClicked, + onMarkAsReadClicked = onMarkAsSeenClicked, + onMarkAsUnreadClicked = onMarkAsUnseenClicked, + onDownloadClicked = onDownloadClicked.takeIf { presenter.selection.none { it.anime.isLocal() } }, + onDeleteClicked = onDeleteClicked, + ) + }, + ) { paddingValues -> + if (presenter.isLoading) { + LoadingScreen() + return@Scaffold + } + + val contentPadding = TachiyomiBottomNavigationView.withBottomNavPadding(paddingValues) + if (presenter.searchQuery.isNullOrEmpty() && presenter.isLibraryEmpty) { + val handler = LocalUriHandler.current + EmptyScreen( + textResource = R.string.information_empty_library, + modifier = Modifier.padding(contentPadding), + actions = listOf( + EmptyScreenAction( + stringResId = R.string.getting_started_guide, + icon = Icons.Outlined.HelpOutline, + onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") }, + ), + ), + ) + return@Scaffold + } + + AnimelibContent( + state = presenter, + contentPadding = contentPadding, + currentPage = { presenter.activeCategory }, + isAnimelibEmpty = presenter.isLibraryEmpty, + showPageTabs = presenter.tabVisibility, + showAnimeCount = presenter.animeCountVisibility, + onChangeCurrentPage = { presenter.activeCategory = it }, + onAnimeClicked = onAnimeClicked, + onToggleSelection = { presenter.toggleSelection(it) }, + onToggleRangeSelection = { presenter.toggleRangeSelection(it) }, + onRefresh = onClickRefresh, + onGlobalSearchClicked = onGlobalSearchClicked, + getNumberOfAnimeForCategory = { presenter.getAnimeCountForCategory(it) }, + getDisplayModeForPage = { presenter.categories[it].display }, + getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) }, + getAnimelibForPage = { presenter.getAnimeForCategory(page = it) }, + showDownloadBadges = presenter.showDownloadBadges, + showUnseenBadges = presenter.showUnseenBadges, + showLocalBadges = presenter.showLocalBadges, + showLanguageBadges = presenter.showLanguageBadges, + isIncognitoMode = presenter.isIncognitoMode, + isDownloadOnly = presenter.isDownloadOnly, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/AnimelibState.kt b/app/src/main/java/eu/kanade/presentation/animelib/AnimelibState.kt new file mode 100644 index 000000000..6732bf5eb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/AnimelibState.kt @@ -0,0 +1,35 @@ +package eu.kanade.presentation.animelib + +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.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.category.model.Category +import eu.kanade.tachiyomi.ui.animelib.AnimelibPresenter + +@Stable +interface AnimelibState { + val isLoading: Boolean + val categories: List + var searchQuery: String? + val selection: List + val selectionMode: Boolean + var hasActiveFilters: Boolean + var dialog: AnimelibPresenter.Dialog? +} + +fun AnimelibState(): AnimelibState { + return AnimelibStateImpl() +} + +class AnimelibStateImpl : AnimelibState { + override var isLoading: Boolean by mutableStateOf(true) + override var categories: List by mutableStateOf(emptyList()) + override var searchQuery: String? by mutableStateOf(null) + override var selection: List by mutableStateOf(emptyList()) + override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() } + override var hasActiveFilters: Boolean by mutableStateOf(false) + override var dialog: AnimelibPresenter.Dialog? by mutableStateOf(null) +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibBadges.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibBadges.kt new file mode 100644 index 000000000..d354d0c3b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibBadges.kt @@ -0,0 +1,53 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.Badge +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem + +@Composable +fun DownloadsBadge( + enabled: Boolean, + item: AnimelibItem, +) { + if (enabled && item.downloadCount > 0) { + Badge( + text = "${item.downloadCount}", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } +} + +@Composable +fun UnreadBadge( + enabled: Boolean, + item: AnimelibItem, +) { + if (enabled && item.unseenCount > 0) { + Badge(text = "${item.unseenCount}") + } +} + +@Composable +fun LanguageBadge( + showLanguage: Boolean, + showLocal: Boolean, + item: AnimelibItem, +) { + if (showLocal && item.isLocal) { + Badge( + text = stringResource(R.string.local_source_badge), + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } else if (showLanguage && item.sourceLanguage.isNotEmpty()) { + Badge( + text = item.sourceLanguage.uppercase(), + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibComfortableGrid.kt new file mode 100644 index 000000000..9388f0c2c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibComfortableGrid.kt @@ -0,0 +1,75 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import eu.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.components.MangaComfortableGridItem +import eu.kanade.presentation.library.components.LazyLibraryGrid +import eu.kanade.presentation.library.components.globalSearchItem +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem + +@Composable +fun AnimelibComfortableGrid( + items: List, + showDownloadBadges: Boolean, + showUnreadBadges: Boolean, + showLocalBadges: Boolean, + showLanguageBadges: Boolean, + columns: Int, + contentPadding: PaddingValues, + selection: List, + onClick: (AnimelibAnime) -> Unit, + onLongClick: (AnimelibAnime) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, +) { + LazyLibraryGrid( + modifier = Modifier.fillMaxSize(), + columns = columns, + contentPadding = contentPadding, + ) { + globalSearchItem(searchQuery, onGlobalSearchClicked) + + items( + items = items, + contentType = { "animelib_comfortable_grid_item" }, + ) { animelibItem -> + val anime = animelibItem.animelibAnime.anime + MangaComfortableGridItem( + isSelected = selection.fastAny { it.id == animelibItem.animelibAnime.id }, + title = anime.title, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + coverBadgeStart = { + DownloadsBadge( + enabled = showDownloadBadges, + item = animelibItem, + ) + UnreadBadge( + enabled = showUnreadBadges, + item = animelibItem, + ) + }, + coverBadgeEnd = { + LanguageBadge( + showLanguage = showLanguageBadges, + showLocal = showLocalBadges, + item = animelibItem, + ) + }, + onLongClick = { onLongClick(animelibItem.animelibAnime) }, + onClick = { onClick(animelibItem.animelibAnime) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibCompactGrid.kt new file mode 100644 index 000000000..d74ffe3e2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibCompactGrid.kt @@ -0,0 +1,76 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import eu.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.components.MangaCompactGridItem +import eu.kanade.presentation.library.components.LazyLibraryGrid +import eu.kanade.presentation.library.components.globalSearchItem +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem + +@Composable +fun AnimelibCompactGrid( + items: List, + showTitle: Boolean, + showDownloadBadges: Boolean, + showUnreadBadges: Boolean, + showLocalBadges: Boolean, + showLanguageBadges: Boolean, + columns: Int, + contentPadding: PaddingValues, + selection: List, + onClick: (AnimelibAnime) -> Unit, + onLongClick: (AnimelibAnime) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, +) { + LazyLibraryGrid( + modifier = Modifier.fillMaxSize(), + columns = columns, + contentPadding = contentPadding, + ) { + globalSearchItem(searchQuery, onGlobalSearchClicked) + + items( + items = items, + contentType = { "animelib_compact_grid_item" }, + ) { animelibItem -> + val anime = animelibItem.animelibAnime.anime + MangaCompactGridItem( + isSelected = selection.fastAny { it.id == animelibItem.animelibAnime.id }, + title = anime.title.takeIf { showTitle }, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + coverBadgeStart = { + DownloadsBadge( + enabled = showDownloadBadges, + item = animelibItem, + ) + UnreadBadge( + enabled = showUnreadBadges, + item = animelibItem, + ) + }, + coverBadgeEnd = { + LanguageBadge( + showLanguage = showLanguageBadges, + showLocal = showLocalBadges, + item = animelibItem, + ) + }, + onLongClick = { onLongClick(animelibItem.animelibAnime) }, + onClick = { onClick(animelibItem.animelibAnime) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibContent.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibContent.kt new file mode 100644 index 000000000..0e2f4f518 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibContent.kt @@ -0,0 +1,129 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.library.model.LibraryDisplayMode +import eu.kanade.presentation.animelib.AnimelibState +import eu.kanade.presentation.components.SwipeRefresh +import eu.kanade.presentation.components.rememberPagerState +import eu.kanade.presentation.library.components.LibraryTabs +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +@Composable +fun AnimelibContent( + state: AnimelibState, + contentPadding: PaddingValues, + currentPage: () -> Int, + isAnimelibEmpty: Boolean, + showPageTabs: Boolean, + showAnimeCount: Boolean, + onChangeCurrentPage: (Int) -> Unit, + onAnimeClicked: (Long) -> Unit, + onToggleSelection: (AnimelibAnime) -> Unit, + onToggleRangeSelection: (AnimelibAnime) -> Unit, + onRefresh: (Category?) -> Boolean, + onGlobalSearchClicked: () -> Unit, + getNumberOfAnimeForCategory: @Composable (Long) -> State, + getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, + getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getAnimelibForPage: @Composable (Int) -> List, + showDownloadBadges: Boolean, + showUnseenBadges: Boolean, + showLocalBadges: Boolean, + showLanguageBadges: Boolean, + isDownloadOnly: Boolean, + isIncognitoMode: Boolean, +) { + Column( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ) { + val categories = state.categories + val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } + val pagerState = rememberPagerState(coercedCurrentPage) + + val scope = rememberCoroutineScope() + var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } + + if (isAnimelibEmpty.not() && showPageTabs && categories.size > 1) { + LibraryTabs( + state = pagerState, + categories = categories, + showMangaCount = showAnimeCount, + getNumberOfMangaForCategory = getNumberOfAnimeForCategory, + isDownloadOnly = isDownloadOnly, + isIncognitoMode = isIncognitoMode, + ) + } + + val onClickAnime = { anime: AnimelibAnime -> + if (state.selectionMode.not()) { + onAnimeClicked(anime.anime.id) + } else { + onToggleSelection(anime) + } + } + val onLongClickAnime = { anime: AnimelibAnime -> + onToggleRangeSelection(anime) + } + + SwipeRefresh( + refreshing = isRefreshing, + onRefresh = { + val started = onRefresh(categories[currentPage()]) + if (!started) return@SwipeRefresh + scope.launch { + // Fake refresh status but hide it after a second as it's a long running task + isRefreshing = true + delay(1.seconds) + isRefreshing = false + } + }, + enabled = state.selectionMode.not(), + ) { + AnimelibPager( + state = pagerState, + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + pageCount = categories.size, + selectedAnime = state.selection, + getDisplayModeForPage = getDisplayModeForPage, + getColumnsForOrientation = getColumnsForOrientation, + getAnimelibForPage = getAnimelibForPage, + showDownloadBadges = showDownloadBadges, + showUnreadBadges = showUnseenBadges, + showLocalBadges = showLocalBadges, + showLanguageBadges = showLanguageBadges, + onClickAnime = onClickAnime, + onLongClickAnime = onLongClickAnime, + onGlobalSearchClicked = onGlobalSearchClicked, + searchQuery = state.searchQuery, + ) + } + + LaunchedEffect(pagerState.currentPage) { + onChangeCurrentPage(pagerState.currentPage) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibList.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibList.kt new file mode 100644 index 000000000..0b22da235 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibList.kt @@ -0,0 +1,86 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.zIndex +import eu.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.MangaListItem +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem + +@Composable +fun AnimelibList( + items: List, + showDownloadBadges: Boolean, + showUnreadBadges: Boolean, + showLocalBadges: Boolean, + showLanguageBadges: Boolean, + contentPadding: PaddingValues, + selection: List, + onClick: (AnimelibAnime) -> Unit, + onLongClick: (AnimelibAnime) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, +) { + FastScrollLazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding + PaddingValues(vertical = 8.dp), + ) { + item { + if (searchQuery.isNullOrEmpty().not()) { + TextButton(onClick = onGlobalSearchClicked) { + Text( + text = stringResource(R.string.action_global_search_query, searchQuery!!), + modifier = Modifier.zIndex(99f), + ) + } + } + } + + items( + items = items, + contentType = { "animelib_list_item" }, + ) { animelibItem -> + val anime = animelibItem.animelibAnime.anime + MangaListItem( + isSelected = selection.fastAny { it.id == animelibItem.animelibAnime.id }, + title = anime.title, + coverData = MangaCover( + mangaId = anime.id, + sourceId = anime.source, + isMangaFavorite = anime.favorite, + url = anime.thumbnailUrl, + lastModified = anime.coverLastModified, + ), + badge = { + DownloadsBadge( + enabled = showDownloadBadges, + item = animelibItem, + ) + UnreadBadge( + enabled = showUnreadBadges, + item = animelibItem, + ) + LanguageBadge( + showLanguage = showLanguageBadges, + showLocal = showLocalBadges, + item = animelibItem, + ) + }, + onLongClick = { onLongClick(animelibItem.animelibAnime) }, + onClick = { onClick(animelibItem.animelibAnime) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibPager.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibPager.kt new file mode 100644 index 000000000..f7c7c4d53 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/AnimelibPager.kt @@ -0,0 +1,110 @@ +package eu.kanade.presentation.animelib.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.domain.animelib.model.AnimelibAnime +import eu.kanade.domain.library.model.LibraryDisplayMode +import eu.kanade.presentation.components.HorizontalPager +import eu.kanade.presentation.components.PagerState +import eu.kanade.tachiyomi.ui.animelib.AnimelibItem + +@Composable +fun AnimelibPager( + state: PagerState, + contentPadding: PaddingValues, + pageCount: Int, + selectedAnime: List, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, + getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, + getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getAnimelibForPage: @Composable (Int) -> List, + showDownloadBadges: Boolean, + showUnreadBadges: Boolean, + showLocalBadges: Boolean, + showLanguageBadges: Boolean, + onClickAnime: (AnimelibAnime) -> Unit, + onLongClickAnime: (AnimelibAnime) -> Unit, +) { + HorizontalPager( + count = pageCount, + modifier = Modifier.fillMaxSize(), + state = state, + verticalAlignment = Alignment.Top, + ) { page -> + if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) { + // To make sure only one offscreen page is being composed + return@HorizontalPager + } + val library = getAnimelibForPage(page) + val displayMode = getDisplayModeForPage(page) + val columns by if (displayMode != LibraryDisplayMode.List) { + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + remember(isLandscape) { getColumnsForOrientation(isLandscape) } + } else { + remember { mutableStateOf(0) } + } + + when (displayMode) { + LibraryDisplayMode.List -> { + AnimelibList( + items = library, + showDownloadBadges = showDownloadBadges, + showUnreadBadges = showUnreadBadges, + showLocalBadges = showLocalBadges, + showLanguageBadges = showLanguageBadges, + contentPadding = contentPadding, + selection = selectedAnime, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { + AnimelibCompactGrid( + items = library, + showTitle = displayMode is LibraryDisplayMode.CompactGrid, + showDownloadBadges = showDownloadBadges, + showUnreadBadges = showUnreadBadges, + showLocalBadges = showLocalBadges, + showLanguageBadges = showLanguageBadges, + columns = columns, + contentPadding = contentPadding, + selection = selectedAnime, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + LibraryDisplayMode.ComfortableGrid -> { + AnimelibComfortableGrid( + items = library, + showDownloadBadges = showDownloadBadges, + showUnreadBadges = showUnreadBadges, + showLocalBadges = showLocalBadges, + showLanguageBadges = showLanguageBadges, + columns = columns, + contentPadding = contentPadding, + selection = selectedAnime, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animelib/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/animelib/components/LibraryToolbar.kt new file mode 100644 index 000000000..1540ffa7e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animelib/components/LibraryToolbar.kt @@ -0,0 +1,169 @@ +package eu.kanade.presentation.animelib.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import eu.kanade.presentation.animelib.AnimelibState +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.Pill +import eu.kanade.presentation.components.SearchToolbar +import eu.kanade.presentation.theme.active +import eu.kanade.tachiyomi.R + +@Composable +fun AnimelibToolbar( + state: AnimelibState, + title: AnimelibToolbarTitle, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, +) = when { + state.selectionMode -> AnimelibSelectionToolbar( + state = state, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + onClickUnselectAll = onClickUnselectAll, + onClickSelectAll = onClickSelectAll, + onClickInvertSelection = onClickInvertSelection, + ) + state.searchQuery != null -> { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + SearchToolbar( + searchQuery = state.searchQuery!!, + onChangeSearchQuery = { state.searchQuery = it }, + onClickCloseSearch = { state.searchQuery = null }, + onClickResetSearch = { state.searchQuery = "" }, + scrollBehavior = scrollBehavior, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + placeholderText = stringResource(R.string.action_search_hint), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + keyboardController?.hide() + }, + ), + ) + } + else -> AnimelibRegularToolbar( + title = title, + hasFilters = state.hasActiveFilters, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + onClickSearch = { state.searchQuery = "" }, + onClickFilter = onClickFilter, + onClickRefresh = onClickRefresh, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +fun AnimelibRegularToolbar( + title: AnimelibToolbarTitle, + hasFilters: Boolean, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, + onClickSearch: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, +) { + val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f + val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current + AppBar( + titleContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title.text, + maxLines = 1, + modifier = Modifier.weight(1f, false), + overflow = TextOverflow.Ellipsis, + ) + if (title.numberOfManga != null) { + Pill( + text = "${title.numberOfManga}", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), + fontSize = 14.sp, + ) + } + } + }, + actions = { + IconButton(onClick = onClickSearch) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search)) + } + IconButton(onClick = onClickFilter) { + Icon(Icons.Outlined.FilterList, contentDescription = stringResource(R.string.action_filter), tint = filterTint) + } + IconButton(onClick = onClickRefresh) { + Icon(Icons.Outlined.Refresh, contentDescription = stringResource(R.string.pref_category_library_update)) + } + }, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +fun AnimelibSelectionToolbar( + state: AnimelibState, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, +) { + AppBar( + titleContent = { Text(text = "${state.selection.size}") }, + actions = { + IconButton(onClick = onClickSelectAll) { + Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all)) + } + IconButton(onClick = onClickInvertSelection) { + Icon(Icons.Outlined.FlipToBack, contentDescription = stringResource(R.string.action_select_inverse)) + } + }, + isActionMode = true, + onCancelActionMode = onClickUnselectAll, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, + ) +} + +data class AnimelibToolbarTitle( + val text: String, + val numberOfManga: Int? = null, +) diff --git a/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesDialog.kt b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesDialog.kt new file mode 100644 index 000000000..a5accc1b7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesDialog.kt @@ -0,0 +1,34 @@ +package eu.kanade.presentation.animeupdates + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.tachiyomi.R + +@Composable +fun AnimeUpdatesDeleteConfirmationDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + text = { + Text(text = stringResource(R.string.confirm_delete_episodes)) + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onConfirm() + onDismissRequest() + },) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesScreen.kt new file mode 100644 index 000000000..071e5c4f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesScreen.kt @@ -0,0 +1,284 @@ +package eu.kanade.presentation.animeupdates + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBarScrollBehavior +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.AnimeBottomActionMenu +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.EpisodeDownloadAction +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.components.SwipeRefresh +import eu.kanade.presentation.updates.updatesLastUpdatedItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateService +import eu.kanade.tachiyomi.ui.player.PlayerActivity +import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences +import eu.kanade.tachiyomi.ui.recent.UpdatesTabsController.Companion.isCurrentUpdateTabManga +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesItem +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesPresenter +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesPresenter.Dialog +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesPresenter.Event +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date +import kotlin.time.Duration.Companion.seconds + +@Composable +fun AnimeUpdateScreen( + presenter: AnimeUpdatesPresenter, + onClickCover: (AnimeUpdatesItem) -> Unit, + onBackClicked: () -> Unit, +) { + val internalOnBackPressed = { + if (presenter.selectionMode) { + presenter.toggleAllSelection(false) + } else { + onBackClicked() + } + } + BackHandler(onBack = internalOnBackPressed) + + isCurrentUpdateTabManga = false + + val context = LocalContext.current + val onUpdateLibrary = { + val started = AnimelibUpdateService.start(context) + context.toast(if (started) R.string.updating_library else R.string.update_already_running) + started + } + + Scaffold( + topBar = { scrollBehavior -> + AnimeUpdatesAppBar( + incognitoMode = presenter.isIncognitoMode, + downloadedOnlyMode = presenter.isDownloadOnly, + onUpdateLibrary = { onUpdateLibrary() }, + actionModeCounter = presenter.selected.size, + onSelectAll = { presenter.toggleAllSelection(true) }, + onInvertSelection = { presenter.invertSelection() }, + onCancelActionMode = { presenter.toggleAllSelection(false) }, + scrollBehavior = scrollBehavior, + ) + }, + bottomBar = { + AnimeUpdatesBottomBar( + selected = presenter.selected, + onDownloadEpisode = presenter::downloadEpisodes, + onMultiBookmarkClicked = presenter::bookmarkUpdates, + onMultiMarkAsSeenClicked = presenter::markUpdatesSeen, + onMultiDeleteClicked = { + presenter.dialog = Dialog.DeleteConfirmation(it) + }, + onOpenEpisode = presenter::openEpisode, + ) + }, + ) { contentPadding -> + val contentPaddingWithNavBar = TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding) + when { + presenter.isLoading -> LoadingScreen() + presenter.uiModels.isEmpty() -> EmptyScreen( + textResource = R.string.information_no_recent, + modifier = Modifier.padding(contentPadding), + ) + else -> { + AnimeUpdateScreenContent( + presenter = presenter, + contentPadding = contentPaddingWithNavBar, + onUpdateLibrary = onUpdateLibrary, + onClickCover = onClickCover, + ) + } + } + } +} + +@Composable +private fun AnimeUpdateScreenContent( + presenter: AnimeUpdatesPresenter, + contentPadding: PaddingValues, + onUpdateLibrary: () -> Boolean, + onClickCover: (AnimeUpdatesItem) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isRefreshing by remember { mutableStateOf(false) } + + SwipeRefresh( + refreshing = isRefreshing, + onRefresh = { + val started = onUpdateLibrary() + if (!started) return@SwipeRefresh + scope.launch { + // Fake refresh status but hide it after a second as it's a long running task + isRefreshing = true + delay(1.seconds) + isRefreshing = false + } + }, + enabled = presenter.selectionMode.not(), + indicatorPadding = contentPadding, + ) { + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + if (presenter.lastUpdated > 0L) { + updatesLastUpdatedItem(presenter.lastUpdated) + } + + animeupdatesUiItems( + uiModels = presenter.uiModels, + selectionMode = presenter.selectionMode, + onUpdateSelected = presenter::toggleSelection, + onClickCover = onClickCover, + onClickUpdate = { + val intent = PlayerActivity.newIntent(context, it.update.animeId, it.update.episodeId) + context.startActivity(intent) + }, + onDownloadEpisode = presenter::downloadEpisodes, + relativeTime = presenter.relativeTime, + dateFormat = presenter.dateFormat, + ) + } + } + + val onDismissDialog = { presenter.dialog = null } + when (val dialog = presenter.dialog) { + is Dialog.DeleteConfirmation -> { + AnimeUpdatesDeleteConfirmationDialog( + onDismissRequest = onDismissDialog, + onConfirm = { + presenter.toggleAllSelection(false) + presenter.deleteEpisodes(dialog.toDelete) + }, + ) + } + null -> {} + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + Event.InternalError -> context.toast(R.string.internal_error) + } + } + } +} + +@Composable +private fun AnimeUpdatesAppBar( + modifier: Modifier = Modifier, + incognitoMode: Boolean, + downloadedOnlyMode: Boolean, + onUpdateLibrary: () -> Unit, + // For action mode + actionModeCounter: Int, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + onCancelActionMode: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, +) { + AppBar( + modifier = modifier, + title = stringResource(R.string.label_recent_updates), + actions = { + IconButton(onClick = onUpdateLibrary) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource(R.string.action_update_library), + ) + } + }, + actionModeCounter = actionModeCounter, + onCancelActionMode = onCancelActionMode, + actionModeActions = { + IconButton(onClick = onSelectAll) { + Icon( + imageVector = Icons.Outlined.SelectAll, + contentDescription = stringResource(R.string.action_select_all), + ) + } + IconButton(onClick = onInvertSelection) { + Icon( + imageVector = Icons.Outlined.FlipToBack, + contentDescription = stringResource(R.string.action_select_inverse), + ) + } + }, + downloadedOnlyMode = downloadedOnlyMode, + incognitoMode = incognitoMode, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +private fun AnimeUpdatesBottomBar( + selected: List, + onDownloadEpisode: (List, EpisodeDownloadAction) -> Unit, + onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, seen: Boolean) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onOpenEpisode: (List, altPlayer: Boolean) -> Unit, +) { + val playerPreferences: PlayerPreferences = Injekt.get() + AnimeBottomActionMenu( + visible = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + onBookmarkClicked = { + onMultiBookmarkClicked.invoke(selected, true) + }.takeIf { selected.any { !it.update.bookmark } }, + onRemoveBookmarkClicked = { + onMultiBookmarkClicked.invoke(selected, false) + }.takeIf { selected.all { it.update.bookmark } }, + onMarkAsSeenClicked = { + onMultiMarkAsSeenClicked(selected, true) + }.takeIf { selected.any { !it.update.seen } }, + onMarkAsUnseenClicked = { + onMultiMarkAsSeenClicked(selected, false) + }.takeIf { selected.any { it.update.seen } }, + onDownloadClicked = { + onDownloadEpisode(selected, EpisodeDownloadAction.START) + }.takeIf { + selected.any { it.downloadStateProvider() != AnimeDownload.State.DOWNLOADED } + }, + onDeleteClicked = { + onMultiDeleteClicked(selected) + }.takeIf { selected.any { it.downloadStateProvider() == AnimeDownload.State.DOWNLOADED } }, + onExternalClicked = { + onOpenEpisode(selected, true) + }.takeIf { !playerPreferences.alwaysUseExternalPlayer().get() && selected.size == 1 }, + onInternalClicked = { + onOpenEpisode(selected, false) + }.takeIf { playerPreferences.alwaysUseExternalPlayer().get() && selected.size == 1 }, + ) +} + +sealed class AnimeUpdatesUiModel { + data class Header(val date: Date) : AnimeUpdatesUiModel() + data class Item(val item: AnimeUpdatesItem) : AnimeUpdatesUiModel() +} diff --git a/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesState.kt b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesState.kt new file mode 100644 index 000000000..ce9c25090 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesState.kt @@ -0,0 +1,51 @@ +package eu.kanade.presentation.animeupdates + +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.kanade.core.util.insertSeparators +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesItem +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesPresenter +import eu.kanade.tachiyomi.util.lang.toDateKey +import java.util.Date + +@Stable +interface AnimeUpdatesState { + val isLoading: Boolean + val items: List + val selected: List + val selectionMode: Boolean + val uiModels: List + var dialog: AnimeUpdatesPresenter.Dialog? +} +fun AnimeUpdatesState(): AnimeUpdatesState = AnimeUpdatesStateImpl() +class AnimeUpdatesStateImpl : AnimeUpdatesState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List by mutableStateOf(emptyList()) + override val selected: List by derivedStateOf { + items.filter { it.selected } + } + override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() } + override val uiModels: List by derivedStateOf { + items.toUpdateUiModel() + } + override var dialog: AnimeUpdatesPresenter.Dialog? by mutableStateOf(null) +} + +fun List.toUpdateUiModel(): List { + return this.map { + AnimeUpdatesUiModel.Item(it) + } + .insertSeparators { before, after -> + val beforeDate = before?.item?.update?.dateFetch?.toDateKey() ?: Date(0) + val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) + when { + beforeDate.time != afterDate.time && afterDate.time != 0L -> + AnimeUpdatesUiModel.Header(afterDate) + // Return null to avoid adding a separator between two items. + else -> null + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesUiItem.kt b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesUiItem.kt new file mode 100644 index 000000000..e5a263bd9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/animeupdates/AnimeUpdatesUiItem.kt @@ -0,0 +1,234 @@ +package eu.kanade.presentation.animeupdates + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations +import eu.kanade.presentation.components.EpisodeDownloadAction +import eu.kanade.presentation.components.EpisodeDownloadIndicator +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.components.RelativeDateHeader +import eu.kanade.presentation.util.ReadItemAlpha +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.ui.recent.animeupdates.AnimeUpdatesItem +import java.text.DateFormat +import java.util.Date +import kotlin.time.Duration.Companion.minutes + +fun LazyListScope.animeupdatesLastUpdatedItem( + lastUpdated: Long, +) { + item(key = "animeupdates-lastUpdated") { + val time = remember(lastUpdated) { + val now = Date().time + if (now - lastUpdated < 1.minutes.inWholeMilliseconds) { + null + } else { + DateUtils.getRelativeTimeSpanString(lastUpdated, now, DateUtils.MINUTE_IN_MILLIS) + } + } + + Box( + modifier = Modifier + .animateItemPlacement() + .padding(horizontal = horizontalPadding, vertical = 8.dp), + ) { + Text( + text = if (time.isNullOrEmpty()) { + stringResource(R.string.updates_last_update_info, stringResource(R.string.updates_last_update_info_just_now)) + } else { + stringResource(R.string.updates_last_update_info, time) + }, + style = LocalTextStyle.current.copy( + fontStyle = FontStyle.Italic, + ), + ) + } + } +} + +fun LazyListScope.animeupdatesUiItems( + uiModels: List, + selectionMode: Boolean, + onUpdateSelected: (AnimeUpdatesItem, Boolean, Boolean, Boolean) -> Unit, + onClickCover: (AnimeUpdatesItem) -> Unit, + onClickUpdate: (AnimeUpdatesItem) -> Unit, + onDownloadEpisode: (List, EpisodeDownloadAction) -> Unit, + relativeTime: Int, + dateFormat: DateFormat, +) { + items( + items = uiModels, + contentType = { + when (it) { + is AnimeUpdatesUiModel.Header -> "header" + is AnimeUpdatesUiModel.Item -> "item" + } + }, + key = { + when (it) { + is AnimeUpdatesUiModel.Header -> "animeupdatesHeader-${it.hashCode()}" + is AnimeUpdatesUiModel.Item -> "animeupdates-${it.item.update.animeId}-${it.item.update.episodeId}" + } + }, + ) { item -> + when (item) { + is AnimeUpdatesUiModel.Header -> { + RelativeDateHeader( + modifier = Modifier.animateItemPlacement(), + date = item.date, + relativeTime = relativeTime, + dateFormat = dateFormat, + ) + } + is AnimeUpdatesUiModel.Item -> { + val updatesItem = item.item + animeupdatesUiItem( + modifier = Modifier.animateItemPlacement(), + update = updatesItem.update, + selected = updatesItem.selected, + onLongClick = { + onUpdateSelected(updatesItem, !updatesItem.selected, true, true) + }, + onClick = { + when { + selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false) + else -> onClickUpdate(updatesItem) + } + }, + onClickCover = { if (selectionMode.not()) onClickCover(updatesItem) }, + onDownloadEpisode = { + if (selectionMode.not()) onDownloadEpisode(listOf(updatesItem), it) + }, + downloadStateProvider = updatesItem.downloadStateProvider, + downloadProgressProvider = updatesItem.downloadProgressProvider, + ) + } + } + } +} + +@Composable +fun animeupdatesUiItem( + modifier: Modifier, + update: AnimeUpdatesWithRelations, + selected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onClickCover: () -> Unit, + onDownloadEpisode: (EpisodeDownloadAction) -> Unit, + // Download Indicator + downloadStateProvider: () -> AnimeDownload.State, + downloadProgressProvider: () -> Int, +) { + val haptic = LocalHapticFeedback.current + Row( + modifier = modifier + .background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) + .combinedClickable( + onClick = onClick, + onLongClick = { + onLongClick() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + .height(56.dp) + .padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Square( + modifier = Modifier + .padding(vertical = 6.dp) + .fillMaxHeight(), + data = update.coverData, + onClick = onClickCover, + ) + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .weight(1f), + ) { + val bookmark = remember(update.bookmark) { update.bookmark } + val seen = remember(update.seen) { update.seen } + + val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f } + + val secondaryTextColor = if (bookmark && !seen) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + + Text( + text = update.animeTitle, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(textAlpha), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableStateOf(0) } + if (bookmark) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = stringResource(R.string.action_filter_bookmarked), + modifier = Modifier + .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(2.dp)) + } + Text( + text = update.episodeName, + maxLines = 1, + style = MaterialTheme.typography.bodySmall + .copy(color = secondaryTextColor), + overflow = TextOverflow.Ellipsis, + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier.alpha(textAlpha), + ) + } + } + EpisodeDownloadIndicator( + modifier = Modifier.padding(start = 4.dp), + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onClick = onDownloadEpisode, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt deleted file mode 100644 index 196e87abc..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package eu.kanade.presentation.browse - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionFilterPresenter -import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionFilterState -import eu.kanade.tachiyomi.ui.browse.animeextension.FilterUiModel - -@Composable -fun AnimeExtensionFilterScreen( - nestedScrollInterop: NestedScrollConnection, - presenter: AnimeExtensionFilterPresenter, - onClickLang: (String) -> Unit, -) { - val state by presenter.state.collectAsState() - - when (state) { - is ExtensionFilterState.Loading -> LoadingScreen() - is ExtensionFilterState.Error -> Text(text = (state as ExtensionFilterState.Error).error.message!!) - is ExtensionFilterState.Success -> - SourceFilterContent( - nestedScrollInterop = nestedScrollInterop, - items = (state as ExtensionFilterState.Success).models, - onClickLang = onClickLang, - ) - } -} - -@Composable -fun SourceFilterContent( - nestedScrollInterop: NestedScrollConnection, - items: List, - onClickLang: (String) -> Unit, -) { - if (items.isEmpty()) { - EmptyScreen(textResource = R.string.empty_screen) - return - } - - LazyColumn( - modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - items( - items = items, - ) { model -> - ExtensionFilterItem( - modifier = Modifier.animateItemPlacement(), - lang = model.lang, - enabled = model.enabled, - onClickItem = onClickLang, - ) - } - } -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt deleted file mode 100644 index 30776ab78..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt +++ /dev/null @@ -1,134 +0,0 @@ -package eu.kanade.presentation.browse - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.browse.components.BaseAnimeSourceItem -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.PreferenceRow -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeFilterUiModel -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceFilterPresenter -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceFilterState -import eu.kanade.tachiyomi.util.system.LocaleHelper - -@Composable -fun AnimeSourceFilterScreen( - nestedScrollInterop: NestedScrollConnection, - presenter: AnimeSourceFilterPresenter, - onClickLang: (String) -> Unit, - onClickSource: (AnimeSource) -> Unit, -) { - val state by presenter.state.collectAsState() - - when (state) { - is AnimeSourceFilterState.Loading -> LoadingScreen() - is AnimeSourceFilterState.Error -> Text(text = (state as AnimeSourceFilterState.Error).error!!.message!!) - is AnimeSourceFilterState.Success -> - AnimeSourceFilterContent( - nestedScrollInterop = nestedScrollInterop, - items = (state as AnimeSourceFilterState.Success).models, - onClickLang = onClickLang, - onClickSource = onClickSource, - ) - } -} - -@Composable -fun AnimeSourceFilterContent( - nestedScrollInterop: NestedScrollConnection, - items: List, - onClickLang: (String) -> Unit, - onClickSource: (AnimeSource) -> Unit, -) { - if (items.isEmpty()) { - EmptyScreen(textResource = R.string.source_filter_empty_screen) - return - } - LazyColumn( - modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - items( - items = items, - contentType = { - when (it) { - is AnimeFilterUiModel.Header -> "header" - is AnimeFilterUiModel.Item -> "item" - } - }, - key = { - when (it) { - is AnimeFilterUiModel.Header -> it.hashCode() - is AnimeFilterUiModel.Item -> it.source.key() - } - }, - ) { model -> - when (model) { - is AnimeFilterUiModel.Header -> { - AnimeSourceFilterHeader( - modifier = Modifier.animateItemPlacement(), - language = model.language, - isEnabled = model.isEnabled, - onClickItem = onClickLang, - ) - } - is AnimeFilterUiModel.Item -> AnimeSourceFilterItem( - modifier = Modifier.animateItemPlacement(), - source = model.source, - isEnabled = model.isEnabled, - onClickItem = onClickSource, - ) - } - } - } -} - -@Composable -fun AnimeSourceFilterHeader( - modifier: Modifier, - language: String, - isEnabled: Boolean, - onClickItem: (String) -> Unit, -) { - PreferenceRow( - modifier = modifier, - title = LocaleHelper.getSourceDisplayName(language, LocalContext.current), - action = { - Switch(checked = isEnabled, onCheckedChange = null) - }, - onClick = { onClickItem(language) }, - ) -} - -@Composable -fun AnimeSourceFilterItem( - modifier: Modifier, - source: AnimeSource, - isEnabled: Boolean, - onClickItem: (AnimeSource) -> Unit, -) { - BaseAnimeSourceItem( - modifier = modifier, - source = source, - showLanguageInContent = false, - onClickItem = { onClickItem(source) }, - action = { - Checkbox(checked = isEnabled, onCheckedChange = null) - }, - ) -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt index b094a1289..3932abe7d 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems -import eu.kanade.data.source.NoResultsException +import eu.kanade.data.chapter.NoChaptersException import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.source.interactor.GetRemoteManga @@ -225,7 +225,7 @@ fun BrowseSourceContent( val getErrorMessage: (LoadState.Error) -> String = { state -> when { - state.error is NoResultsException -> context.getString(R.string.no_results_found) + state.error is NoChaptersException -> context.getString(R.string.no_results_found) state.error.message.isNullOrEmpty() -> "" state.error.message.orEmpty().startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}" else -> state.error.message.orEmpty() diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 7c528092a..3c4c02c94 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -405,7 +405,7 @@ private fun SourceSwitchPreference( } @Composable -private fun NsfwWarningDialog( +fun NsfwWarningDialog( onClickConfirm: () -> Unit, ) { AlertDialog( diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 05f4d7a7b..ec1b654e3 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -377,7 +377,7 @@ private fun ExtensionItemActions( } @Composable -private fun ExtensionHeader( +fun ExtensionHeader( @StringRes textRes: Int, modifier: Modifier = Modifier, action: @Composable RowScope.() -> Unit = {}, @@ -390,7 +390,7 @@ private fun ExtensionHeader( } @Composable -private fun ExtensionHeader( +fun ExtensionHeader( text: String, modifier: Modifier = Modifier, action: @Composable RowScope.() -> Unit = {}, @@ -411,7 +411,7 @@ private fun ExtensionHeader( } @Composable -private fun ExtensionTrustDialog( +fun ExtensionTrustDialog( onClickConfirm: () -> Unit, onClickDismiss: () -> Unit, onDismissRequest: () -> Unit, diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt deleted file mode 100644 index 6cad947bd..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.kanade.presentation.browse - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import eu.kanade.domain.anime.model.Anime -import eu.kanade.presentation.anime.components.BaseAnimeListItem -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.ScrollbarLazyColumn -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrateAnimeState -import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrationAnimePresenter - -@Composable -fun MigrateAnimeScreen( - nestedScrollInterop: NestedScrollConnection, - presenter: MigrationAnimePresenter, - onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit, -) { - val state by presenter.state.collectAsState() - - when (state) { - MigrateAnimeState.Loading -> LoadingScreen() - is MigrateAnimeState.Error -> Text(text = (state as MigrateAnimeState.Error).error.message!!) - is MigrateAnimeState.Success -> { - MigrateAnimeContent( - nestedScrollInterop = nestedScrollInterop, - list = (state as MigrateAnimeState.Success).list, - onClickItem = onClickItem, - onClickCover = onClickCover, - ) - } - } -} - -@Composable -fun MigrateAnimeContent( - nestedScrollInterop: NestedScrollConnection, - list: List, - onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit, -) { - if (list.isEmpty()) { - EmptyScreen(textResource = R.string.empty_screen) - return - } - ScrollbarLazyColumn( - modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - items(list) { anime -> - MigrateAnimeItem( - anime = anime, - onClickItem = onClickItem, - onClickCover = onClickCover, - ) - } - } -} - -@Composable -fun MigrateAnimeItem( - modifier: Modifier = Modifier, - anime: Anime, - onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit, -) { - BaseAnimeListItem( - modifier = modifier, - anime = anime, - onClickItem = { onClickItem(anime) }, - onClickCover = { onClickCover(anime) }, - ) -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt deleted file mode 100644 index 04fc6fe64..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt +++ /dev/null @@ -1,156 +0,0 @@ -package eu.kanade.presentation.browse - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.browse.components.AnimeSourceIcon -import eu.kanade.presentation.browse.components.BaseAnimeSourceItem -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.ItemBadges -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.ScrollbarLazyColumn -import eu.kanade.presentation.theme.header -import eu.kanade.presentation.util.horizontalPadding -import eu.kanade.presentation.util.plus -import eu.kanade.presentation.util.topPaddingValues -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrateAnimeSourceState -import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrationAnimeSourcesPresenter -import eu.kanade.tachiyomi.util.system.LocaleHelper - -@Composable -fun MigrateAnimeSourceScreen( - nestedScrollInterop: NestedScrollConnection, - presenter: MigrationAnimeSourcesPresenter, - onClickItem: (AnimeSource) -> Unit, - onLongClickItem: (AnimeSource) -> Unit, -) { - val state by presenter.state.collectAsState() - when (state) { - is MigrateAnimeSourceState.Loading -> LoadingScreen() - is MigrateAnimeSourceState.Error -> Text(text = (state as MigrateAnimeSourceState.Error).error.message!!) - is MigrateAnimeSourceState.Success -> - MigrateAnimeSourceList( - nestedScrollInterop = nestedScrollInterop, - list = (state as MigrateAnimeSourceState.Success).sources, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - ) - } -} - -@Composable -fun MigrateAnimeSourceList( - nestedScrollInterop: NestedScrollConnection, - list: List>, - onClickItem: (AnimeSource) -> Unit, - onLongClickItem: (AnimeSource) -> Unit, -) { - if (list.isEmpty()) { - EmptyScreen(textResource = R.string.information_empty_library) - return - } - - ScrollbarLazyColumn( - modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, - ) { - item(key = "title") { - Text( - text = stringResource(R.string.migration_selection_prompt), - modifier = Modifier - .animateItemPlacement() - .padding(horizontal = horizontalPadding, vertical = 8.dp), - style = MaterialTheme.typography.header, - ) - } - - items( - items = list, - key = { (source, _) -> - source.id - }, - ) { (source, count) -> - MigrateAnimeSourceItem( - modifier = Modifier.animateItemPlacement(), - source = source, - count = count, - onClickItem = { onClickItem(source) }, - onLongClickItem = { onLongClickItem(source) }, - ) - } - } -} - -@Composable -fun MigrateAnimeSourceItem( - modifier: Modifier = Modifier, - source: AnimeSource, - count: Long, - onClickItem: () -> Unit, - onLongClickItem: () -> Unit, -) { - BaseAnimeSourceItem( - modifier = modifier, - source = source, - showLanguageInContent = source.lang != "", - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - icon = { AnimeSourceIcon(source = source) }, - action = { ItemBadges(primaryText = "$count") }, - content = { source, showLanguageInContent -> - Column( - modifier = Modifier - .padding(horizontal = horizontalPadding) - .weight(1f), - ) { - Text( - text = source.name.ifBlank { source.id.toString() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showLanguageInContent) { - Text( - text = LocaleHelper.getDisplayName(source.lang), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - } - if (source.isStub) { - Text( - text = stringResource(R.string.not_installed), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - }, - ) -} diff --git a/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryScreen.kt b/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryScreen.kt new file mode 100644 index 000000000..de3942579 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryScreen.kt @@ -0,0 +1,105 @@ +package eu.kanade.presentation.category + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.category.components.AnimeCategoryContent +import eu.kanade.presentation.category.components.CategoryCreateDialog +import eu.kanade.presentation.category.components.CategoryDeleteDialog +import eu.kanade.presentation.category.components.CategoryFloatingActionButton +import eu.kanade.presentation.category.components.CategoryRenameDialog +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.animecategory.AnimeCategoryPresenter +import eu.kanade.tachiyomi.ui.animecategory.AnimeCategoryPresenter.Dialog +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun AnimeCategoryScreen( + presenter: AnimeCategoryPresenter, + navigateUp: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.action_edit_categories), + navigateUp = navigateUp, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + CategoryFloatingActionButton( + lazyListState = lazyListState, + onCreate = { presenter.dialog = Dialog.Create }, + ) + }, + ) { paddingValues -> + val context = LocalContext.current + when { + presenter.isLoading -> LoadingScreen() + presenter.isEmpty -> EmptyScreen( + textResource = R.string.information_empty_category, + modifier = Modifier.padding(paddingValues), + ) + else -> { + AnimeCategoryContent( + state = presenter, + lazyListState = lazyListState, + paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding), + onMoveUp = { presenter.moveUp(it) }, + onMoveDown = { presenter.moveDown(it) }, + ) + } + } + + val onDismissRequest = { presenter.dialog = null } + when (val dialog = presenter.dialog) { + Dialog.Create -> { + CategoryCreateDialog( + onDismissRequest = onDismissRequest, + onCreate = { presenter.createCategory(it) }, + ) + } + is Dialog.Rename -> { + CategoryRenameDialog( + onDismissRequest = onDismissRequest, + onRename = { presenter.renameCategory(dialog.category, it) }, + category = dialog.category, + ) + } + is Dialog.Delete -> { + CategoryDeleteDialog( + onDismissRequest = onDismissRequest, + onDelete = { presenter.deleteCategory(dialog.category) }, + category = dialog.category, + ) + } + else -> {} + } + LaunchedEffect(Unit) { + presenter.events.collectLatest { event -> + when (event) { + is AnimeCategoryPresenter.Event.CategoryWithNameAlreadyExists -> { + context.toast(R.string.error_category_exists) + } + is AnimeCategoryPresenter.Event.InternalError -> { + context.toast(R.string.internal_error) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryState.kt b/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryState.kt new file mode 100644 index 000000000..445c74c49 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/AnimeCategoryState.kt @@ -0,0 +1,28 @@ +package eu.kanade.presentation.category + +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.kanade.domain.category.model.Category +import eu.kanade.tachiyomi.ui.animecategory.AnimeCategoryPresenter + +@Stable +interface AnimeCategoryState { + val isLoading: Boolean + var dialog: AnimeCategoryPresenter.Dialog? + val categories: List + val isEmpty: Boolean +} + +fun AnimeCategoryState(): AnimeCategoryState { + return AnimeCategoryStateImpl() +} + +class AnimeCategoryStateImpl : AnimeCategoryState { + override var isLoading: Boolean by mutableStateOf(true) + override var dialog: AnimeCategoryPresenter.Dialog? by mutableStateOf(null) + override var categories: List by mutableStateOf(emptyList()) + override val isEmpty: Boolean by derivedStateOf { categories.isEmpty() } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/AnimeCategoryContent.kt b/app/src/main/java/eu/kanade/presentation/category/components/AnimeCategoryContent.kt new file mode 100644 index 000000000..a1757e794 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/AnimeCategoryContent.kt @@ -0,0 +1,45 @@ +package eu.kanade.presentation.category.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.domain.category.model.Category +import eu.kanade.presentation.category.AnimeCategoryState +import eu.kanade.presentation.components.LazyColumn +import eu.kanade.tachiyomi.ui.animecategory.AnimeCategoryPresenter.Dialog + +@Composable +fun AnimeCategoryContent( + state: AnimeCategoryState, + lazyListState: LazyListState, + paddingValues: PaddingValues, + onMoveUp: (Category) -> Unit, + onMoveDown: (Category) -> Unit, +) { + val categories = state.categories + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = categories, + key = { _, category -> "category-${category.id}" }, + ) { index, category -> + CategoryListItem( + modifier = Modifier.animateItemPlacement(), + category = category, + canMoveUp = index != 0, + canMoveDown = index != categories.lastIndex, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + onRename = { state.dialog = Dialog.Rename(category) }, + onDelete = { state.dialog = Dialog.Delete(category) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/components/AnimeBottomActionMenu.kt similarity index 83% rename from app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt rename to app/src/main/java/eu/kanade/presentation/components/AnimeBottomActionMenu.kt index 2f86eb4e7..fc0c3357b 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/AnimeBottomActionMenu.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.anime.components +package eu.kanade.presentation.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState @@ -12,18 +12,23 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.ZeroCornerSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BookmarkAdd -import androidx.compose.material.icons.filled.BookmarkRemove -import androidx.compose.material.icons.filled.DoneAll -import androidx.compose.material.icons.filled.RemoveDone +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.BookmarkRemove import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Input import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -48,20 +53,21 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds @Composable fun AnimeBottomActionMenu( visible: Boolean, modifier: Modifier = Modifier, - onBookmarkClicked: (() -> Unit)?, - onRemoveBookmarkClicked: (() -> Unit)?, - onMarkAsSeenClicked: (() -> Unit)?, - onMarkAsUnseenClicked: (() -> Unit)?, - onMarkPreviousAsSeenClicked: (() -> Unit)?, - onDownloadClicked: (() -> Unit)?, - onDeleteClicked: (() -> Unit)?, - onExternalClicked: (() -> Unit)?, - onInternalClicked: (() -> Unit)?, + onBookmarkClicked: (() -> Unit)? = null, + onRemoveBookmarkClicked: (() -> Unit)? = null, + onMarkAsSeenClicked: (() -> Unit)? = null, + onMarkAsUnseenClicked: (() -> Unit)? = null, + onMarkPreviousAsSeenClicked: (() -> Unit)? = null, + onDownloadClicked: (() -> Unit)? = null, + onDeleteClicked: (() -> Unit)? = null, + onExternalClicked: (() -> Unit)? = null, + onInternalClicked: (() -> Unit)? = null, ) { AnimatedVisibility( visible = visible, @@ -71,7 +77,7 @@ fun AnimeBottomActionMenu( val scope = rememberCoroutineScope() Surface( modifier = modifier, - shape = MaterialTheme.shapes.large, + shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), tonalElevation = 3.dp, ) { val haptic = LocalHapticFeedback.current @@ -82,19 +88,19 @@ fun AnimeBottomActionMenu( (0 until 9).forEach { i -> confirm[i] = i == toConfirmIndex } resetJob?.cancel() resetJob = scope.launch { - delay(1000) + delay(1.seconds) if (isActive) confirm[toConfirmIndex] = false } } Row( modifier = Modifier - .navigationBarsPadding() + .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()) .padding(horizontal = 8.dp, vertical = 12.dp), ) { if (onBookmarkClicked != null) { Button( title = stringResource(R.string.action_bookmark_episode), - icon = Icons.Default.BookmarkAdd, + icon = Icons.Outlined.BookmarkAdd, toConfirm = confirm[0], onLongClick = { onLongClickItem(0) }, onClick = onBookmarkClicked, @@ -103,7 +109,7 @@ fun AnimeBottomActionMenu( if (onRemoveBookmarkClicked != null) { Button( title = stringResource(R.string.action_remove_bookmark_episode), - icon = Icons.Default.BookmarkRemove, + icon = Icons.Outlined.BookmarkRemove, toConfirm = confirm[1], onLongClick = { onLongClickItem(1) }, onClick = onRemoveBookmarkClicked, @@ -112,7 +118,7 @@ fun AnimeBottomActionMenu( if (onMarkAsSeenClicked != null) { Button( title = stringResource(R.string.action_mark_as_seen), - icon = Icons.Default.DoneAll, + icon = Icons.Outlined.DoneAll, toConfirm = confirm[2], onLongClick = { onLongClickItem(2) }, onClick = onMarkAsSeenClicked, @@ -121,7 +127,7 @@ fun AnimeBottomActionMenu( if (onMarkAsUnseenClicked != null) { Button( title = stringResource(R.string.action_mark_as_unseen), - icon = Icons.Default.RemoveDone, + icon = Icons.Outlined.RemoveDone, toConfirm = confirm[3], onLongClick = { onLongClickItem(3) }, onClick = onMarkAsUnseenClicked, diff --git a/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt b/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt index fd817fc25..7d8151f2e 100644 --- a/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt +++ b/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt @@ -205,7 +205,7 @@ private fun ErrorIndicator( ) { Icon( imageVector = Icons.Outlined.ErrorOutline, - contentDescription = stringResource(R.string.chapter_error), + contentDescription = stringResource(R.string.download_error), modifier = Modifier.size(IndicatorSize), tint = MaterialTheme.colorScheme.error, ) diff --git a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt index a0ed279d7..057d89766 100644 --- a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt @@ -1,20 +1,29 @@ package eu.kanade.presentation.components +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.sizeIn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RadioButtonChecked import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import eu.kanade.tachiyomi.R +import me.saket.cascade.CascadeColumnScope +import me.saket.cascade.CascadeDropdownMenu import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu @Composable @@ -61,3 +70,27 @@ fun RadioMenuItem( }, ) } + +@Composable +fun OverflowMenu( + content: @Composable CascadeColumnScope.(() -> Unit) -> Unit, +) { + var moreExpanded by remember { mutableStateOf(false) } + val closeMenu = { moreExpanded = false } + + Box { + IconButton(onClick = { moreExpanded = !moreExpanded }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = stringResource(R.string.abc_action_menu_overflow_description), + ) + } + CascadeDropdownMenu( + expanded = moreExpanded, + onDismissRequest = closeMenu, + offset = DpOffset(8.dp, (-56).dp), + ) { + content(closeMenu) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/DuplicateAnimeDialog.kt b/app/src/main/java/eu/kanade/presentation/components/DuplicateAnimeDialog.kt new file mode 100644 index 000000000..c8f382fa2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/DuplicateAnimeDialog.kt @@ -0,0 +1,57 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.AnimeSource + +@Composable +fun DuplicateAnimeDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + onOpenAnime: () -> Unit, + duplicateFrom: AnimeSource, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + Row { + TextButton(onClick = { + onDismissRequest() + onOpenAnime() + },) { + Text(text = stringResource(R.string.action_show_manga)) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + TextButton( + onClick = { + onDismissRequest() + onConfirm() + }, + ) { + Text(text = stringResource(R.string.action_add)) + } + } + }, + title = { + Text(text = stringResource(R.string.are_you_sure)) + }, + text = { + Text( + text = stringResource( + id = R.string.confirm_manga_add_duplicate, + duplicateFrom.name, + ), + ) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/components/EpisodeDownloadIndicator.kt b/app/src/main/java/eu/kanade/presentation/components/EpisodeDownloadIndicator.kt index 9a67dcd5f..5cf2139cd 100644 --- a/app/src/main/java/eu/kanade/presentation/components/EpisodeDownloadIndicator.kt +++ b/app/src/main/java/eu/kanade/presentation/components/EpisodeDownloadIndicator.kt @@ -1,156 +1,261 @@ package eu.kanade.presentation.components import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.LocalMinimumTouchTargetEnforcement import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import eu.kanade.presentation.manga.EpisodeDownloadAction +import eu.kanade.domain.download.service.DownloadPreferences import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import uy.kohesive.injekt.injectLazy + +enum class EpisodeDownloadAction { + START, + START_NOW, + CANCEL, + DELETE, + START_ALT, +} @Composable fun EpisodeDownloadIndicator( modifier: Modifier = Modifier, - downloadState: AnimeDownload.State, - downloadProgress: Int, + downloadStateProvider: () -> AnimeDownload.State, + downloadProgressProvider: () -> Int, onClick: (EpisodeDownloadAction) -> Unit, ) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) { - val isDownloaded = downloadState == AnimeDownload.State.DOWNLOADED - val isDownloading = downloadState != AnimeDownload.State.NOT_DOWNLOADED - var isMenuExpanded by remember(downloadState) { mutableStateOf(false) } - IconButton( - onClick = { - if (isDownloaded || isDownloading) { - isMenuExpanded = true - } else { - onClick(EpisodeDownloadAction.START) - } - }, - onLongClick = { - val episodeDownloadAction = when { - isDownloaded -> EpisodeDownloadAction.DELETE - isDownloading -> EpisodeDownloadAction.CANCEL - else -> EpisodeDownloadAction.START_NOW - } - onClick(episodeDownloadAction) - }, - ) { - val indicatorModifier = Modifier - .size(IndicatorSize) - .padding(IndicatorPadding) - if (isDownloaded) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = indicatorModifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_delete)) }, - onClick = { - onClick(EpisodeDownloadAction.DELETE) - isMenuExpanded = false - }, - ) - } - } else { - val inactiveAlphaModifier = if (!isDownloading) Modifier.secondaryItemAlpha() else Modifier - val arrowModifier = Modifier - .size(IndicatorSize - 7.dp) - .then(inactiveAlphaModifier) - val arrowColor: Color - val strokeColor = MaterialTheme.colorScheme.onSurfaceVariant - if (isDownloading) { - val indeterminate = downloadState == AnimeDownload.State.QUEUE || - (downloadState == AnimeDownload.State.DOWNLOADING && downloadProgress == 0) - if (indeterminate) { - arrowColor = strokeColor - CircularProgressIndicator( - modifier = indicatorModifier, - color = strokeColor, - strokeWidth = IndicatorStrokeWidth, - ) - } else { - val animatedProgress by animateFloatAsState( - targetValue = downloadProgress / 100f, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ) - arrowColor = if (animatedProgress < 0.5f) { - strokeColor - } else { - MaterialTheme.colorScheme.background - } - CircularProgressIndicator( - progress = animatedProgress, - modifier = indicatorModifier, - color = strokeColor, - strokeWidth = IndicatorSize / 2, - ) - } - } else { - arrowColor = strokeColor - CircularProgressIndicator( - progress = 1f, - modifier = indicatorModifier.then(inactiveAlphaModifier), - color = strokeColor, - strokeWidth = IndicatorStrokeWidth, - ) - } - Icon( - imageVector = Icons.Default.ArrowDownward, - contentDescription = null, - modifier = arrowModifier, - tint = arrowColor, - ) - DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_start_downloading_now)) }, - onClick = { - onClick(EpisodeDownloadAction.START_NOW) - isMenuExpanded = false - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_cancel)) }, - onClick = { - onClick(EpisodeDownloadAction.CANCEL) - isMenuExpanded = false - }, - ) - } - } + when (val downloadState = downloadStateProvider()) { + AnimeDownload.State.NOT_DOWNLOADED -> NotDownloadedIndicator(modifier = modifier, onClick = onClick) + AnimeDownload.State.QUEUE, AnimeDownload.State.DOWNLOADING -> DownloadingIndicator( + modifier = modifier, + downloadState = downloadState, + downloadProgressProvider = downloadProgressProvider, + onClick = onClick, + ) + AnimeDownload.State.DOWNLOADED -> DownloadedIndicator(modifier = modifier, onClick = onClick) + AnimeDownload.State.ERROR -> ErrorIndicator(modifier = modifier, onClick = onClick) + } +} + +@Composable +private fun NotDownloadedIndicator( + modifier: Modifier = Modifier, + onClick: (EpisodeDownloadAction) -> Unit, +) { + Box( + modifier = modifier + .size(IconButtonTokens.StateLayerSize) + .commonClickable( + onLongClick = { onClick(EpisodeDownloadAction.START_NOW) }, + onClick = { onClick(EpisodeDownloadAction.START) }, + ) + .secondaryItemAlpha(), + contentAlignment = Alignment.Center, + ) { + var isMenuExpanded by remember { mutableStateOf(false) } + DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { + val downloadText = if (preferences.useExternalDownloader().get()) { + stringResource(R.string.action_start_download_internally) + } else { + stringResource(R.string.action_start_download_externally) } + DropdownMenuItem( + text = { Text(text = downloadText) }, + onClick = { + onClick(EpisodeDownloadAction.START_ALT) + isMenuExpanded = false + }, + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_download_chapter_24dp), + contentDescription = stringResource(R.string.manga_download), + modifier = Modifier.size(IndicatorSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun DownloadingIndicator( + modifier: Modifier = Modifier, + downloadState: AnimeDownload.State, + downloadProgressProvider: () -> Int, + onClick: (EpisodeDownloadAction) -> Unit, +) { + var isMenuExpanded by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(IconButtonTokens.StateLayerSize) + .commonClickable( + onLongClick = { onClick(EpisodeDownloadAction.CANCEL) }, + onClick = { isMenuExpanded = true }, + ), + contentAlignment = Alignment.Center, + ) { + val arrowColor: Color + val strokeColor = MaterialTheme.colorScheme.onSurfaceVariant + val downloadProgress = downloadProgressProvider() + val indeterminate = downloadState == AnimeDownload.State.QUEUE || + (downloadState == AnimeDownload.State.DOWNLOADING && downloadProgress == 0) + if (indeterminate) { + arrowColor = strokeColor + CircularProgressIndicator( + modifier = IndicatorModifier, + color = strokeColor, + strokeWidth = IndicatorStrokeWidth, + ) + } else { + val animatedProgress by animateFloatAsState( + targetValue = downloadProgress / 100f, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + arrowColor = if (animatedProgress < 0.5f) { + strokeColor + } else { + MaterialTheme.colorScheme.background + } + CircularProgressIndicator( + progress = animatedProgress, + modifier = IndicatorModifier, + color = strokeColor, + strokeWidth = IndicatorSize / 2, + ) + } + DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_start_downloading_now)) }, + onClick = { + onClick(EpisodeDownloadAction.START_NOW) + isMenuExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_cancel)) }, + onClick = { + onClick(EpisodeDownloadAction.CANCEL) + isMenuExpanded = false + }, + ) + } + Icon( + imageVector = Icons.Outlined.ArrowDownward, + contentDescription = null, + modifier = ArrowModifier, + tint = arrowColor, + ) + } +} + +@Composable +private fun DownloadedIndicator( + modifier: Modifier = Modifier, + onClick: (EpisodeDownloadAction) -> Unit, +) { + var isMenuExpanded by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(IconButtonTokens.StateLayerSize) + .commonClickable( + onLongClick = { onClick(EpisodeDownloadAction.DELETE) }, + onClick = { isMenuExpanded = true }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(IndicatorSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_delete)) }, + onClick = { + onClick(EpisodeDownloadAction.DELETE) + isMenuExpanded = false + }, + ) } } } +@Composable +private fun ErrorIndicator( + modifier: Modifier = Modifier, + onClick: (EpisodeDownloadAction) -> Unit, +) { + Box( + modifier = modifier + .size(IconButtonTokens.StateLayerSize) + .commonClickable( + onLongClick = { onClick(EpisodeDownloadAction.START) }, + onClick = { onClick(EpisodeDownloadAction.START) }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = stringResource(R.string.download_error), + modifier = Modifier.size(IndicatorSize), + tint = MaterialTheme.colorScheme.error, + ) + } +} + +private fun Modifier.commonClickable( + onLongClick: () -> Unit, + onClick: () -> Unit, +) = composed { + this.combinedClickable( + onLongClick = onLongClick, + onClick = onClick, + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = IconButtonTokens.StateLayerSize / 2, + ), + ) +} + private val IndicatorSize = 26.dp private val IndicatorPadding = 2.dp // To match composable parameter name when used later private val IndicatorStrokeWidth = IndicatorPadding + +private val IndicatorModifier = Modifier + .size(IndicatorSize) + .padding(IndicatorPadding) +private val ArrowModifier = Modifier + .size(IndicatorSize - 7.dp) + +private val preferences: DownloadPreferences by injectLazy() diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 2e72570d9..0e4289c82 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -18,6 +18,7 @@ import eu.kanade.presentation.history.components.HistoryToolbar import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.recent.HistoryTabsController.Companion.isCurrentHistoryTabManga import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter.Dialog import eu.kanade.tachiyomi.util.system.toast @@ -33,6 +34,8 @@ fun HistoryScreen( ) { val context = LocalContext.current + isCurrentHistoryTabManga = true + Scaffold( topBar = { scrollBehavior -> HistoryToolbar( @@ -66,7 +69,7 @@ fun HistoryScreen( LaunchedEffect(items) { if (items != null) { - (presenter.view?.activity as? MainActivity)?.ready = true + (presenter.context as? MainActivity)?.ready = true } } } diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt index 78085962e..42d512dd7 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt @@ -121,7 +121,7 @@ fun AnimeHistoryItem( fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = textStyle + style = textStyle, ) val seenAt = remember { history.seenAt?.toTimestampString() ?: "" } Text( diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryToolbar.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryToolbar.kt index e68d72579..7fb410da2 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryToolbar.kt @@ -30,7 +30,7 @@ fun HistoryToolbar( val focusManager = LocalFocusManager.current if (state.searchQuery == null) { - HistoryRegularToolbar( + eu.kanade.presentation.animehistory.components.HistoryRegularToolbar( onClickSearch = { state.searchQuery = "" }, onClickDelete = { state.dialog = HistoryPresenter.Dialog.DeleteAll }, incognitoMode = incognitoMode, diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt index 35a875783..e98e85c54 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt @@ -9,14 +9,6 @@ enum class DownloadAction { ALL_CHAPTERS, } -enum class EpisodeDownloadAction { - START, - START_NOW, - CANCEL, - DELETE, - START_ALT, -} - enum class EditCoverAction { EDIT, DELETE, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt index 28f906011..938f62752 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt @@ -52,6 +52,7 @@ fun MangaToolbar( onClickDownload: ((DownloadAction) -> Unit)?, onClickEditCategory: (() -> Unit)?, onClickMigrate: (() -> Unit)?, + changeAnimeSkipIntro: (() -> Unit)? = null, // For action mode actionModeCounter: Int, onSelectAll: () -> Unit, @@ -186,6 +187,15 @@ fun MangaToolbar( onDismissRequest() }, ) + if (changeAnimeSkipIntro != null) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_change_intro_length)) }, + onClick = { + changeAnimeSkipIntro.invoke() + onDismissRequest() + }, + ) + } if (onClickShare != null) { DropdownMenuItem( text = { Text(text = stringResource(R.string.action_share)) }, diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 6cf4ab6c3..bc0061de5 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -20,24 +20,26 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import eu.kanade.domain.library.service.LibraryPreferences import eu.kanade.presentation.components.AppStateBanners import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.more.AnimeDownloadQueueState import eu.kanade.tachiyomi.ui.more.DownloadQueueState import eu.kanade.tachiyomi.ui.more.MoreController import eu.kanade.tachiyomi.ui.more.MorePresenter +import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView @Composable fun MoreScreen( presenter: MorePresenter, onClickHistory: () -> Unit, + onClickAnimeDownloadQueue: () -> Unit, onClickDownloadQueue: () -> Unit, onClickAnimeCategories: () -> Unit, onClickCategories: () -> Unit, @@ -48,7 +50,9 @@ fun MoreScreen( val uriHandler = LocalUriHandler.current val downloadQueueState by presenter.downloadQueueState.collectAsState() - val preferences: PreferencesHelper = Injekt.get() + val animeDownloadQueueState by presenter.animeDownloadQueueState.collectAsState() + + val libraryPreferences: LibraryPreferences = Injekt.get() ScrollbarLazyColumn( modifier = Modifier.statusBarsPadding(), @@ -87,21 +91,49 @@ fun MoreScreen( item { Divider() } item { - val bottomNavStyle = preferences.bottomNavStyle() - val titleRes = when (preferences.bottomNavStyle()) { + val bottomNavStyle = libraryPreferences.bottomNavStyle().get() + val titleRes = when (bottomNavStyle) { 1 -> R.string.label_recent_updates 2 -> R.string.label_manga else -> R.string.label_recent_manga } - val painter = when (bottomNavStyle) { - 1 -> painterResource(R.drawable.ic_updates_outline_24dp) - 2 -> rememberVectorPainter(Icons.Outlined.CollectionsBookmark) - else -> rememberVectorPainter(Icons.Outlined.History) + val icon = when (bottomNavStyle) { + 1 -> ImageVector.vectorResource(id = R.drawable.ic_updates_outline_24dp) + 2 -> Icons.Outlined.CollectionsBookmark + else -> Icons.Outlined.History } - PreferenceRow( + TextPreferenceWidget( title = stringResource(titleRes), - painter = painter, - onClick = onClickHistory, + icon = icon, + onPreferenceClick = onClickHistory, + ) + } + item { + TextPreferenceWidget( + title = stringResource(R.string.label_anime_download_queue), + subtitle = when (animeDownloadQueueState) { + AnimeDownloadQueueState.Stopped -> null + is AnimeDownloadQueueState.Paused -> { + val pending = (animeDownloadQueueState as AnimeDownloadQueueState.Paused).pending + if (pending == 0) { + stringResource(R.string.paused) + } else { + "${stringResource(R.string.paused)} • ${ + pluralStringResource( + id = R.plurals.download_queue_summary, + count = pending, + pending, + ) + }" + } + } + is AnimeDownloadQueueState.Downloading -> { + val pending = (animeDownloadQueueState as AnimeDownloadQueueState.Downloading).pending + pluralStringResource(id = R.plurals.download_queue_summary, count = pending, pending) + } + }, + icon = Icons.Outlined.GetApp, + onPreferenceClick = onClickAnimeDownloadQueue, ) } item { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt index 9724b8e65..ddb6f80f0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt @@ -114,7 +114,7 @@ class AboutScreen : Screen { item { TextPreferenceWidget( title = stringResource(R.string.help_translate), - onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/help/contribution/#translation") }, + onPreferenceClick = { uriHandler.openUri("https://aniyomi.jmir.xyz/help/contribution/#translation") }, ) } @@ -128,7 +128,7 @@ class AboutScreen : Screen { item { TextPreferenceWidget( title = stringResource(R.string.privacy_policy), - onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/privacy") }, + onPreferenceClick = { uriHandler.openUri("https://aniyomi.jmir.xyz/privacy") }, ) } @@ -142,32 +142,22 @@ class AboutScreen : Screen { LinkIcon( label = stringResource(R.string.website), painter = rememberVectorPainter(Icons.Outlined.Public), - url = "https://tachiyomi.org", + url = "https://aniyomi.jmir.xyz", ) LinkIcon( label = "Discord", painter = painterResource(R.drawable.ic_discord_24dp), - url = "https://discord.gg/tachiyomi", - ) - LinkIcon( - label = "Twitter", - painter = painterResource(R.drawable.ic_twitter_24dp), - url = "https://twitter.com/tachiyomiorg", - ) - LinkIcon( - label = "Facebook", - painter = painterResource(R.drawable.ic_facebook_24dp), - url = "https://facebook.com/tachiyomiorg", + url = "https://discord.gg/F32UjdJZrR", ) LinkIcon( label = "Reddit", painter = painterResource(R.drawable.ic_reddit_24dp), - url = "https://www.reddit.com/r/Tachiyomi", + url = "https://www.reddit.com/r/Aniyomi", ) LinkIcon( label = "GitHub", painter = painterResource(R.drawable.ic_github_24dp), - url = "https://github.com/tachiyomiorg", + url = "https://github.com/jmir1/aniyomi", ) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearAnimeDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearAnimeDatabaseScreen.kt new file mode 100644 index 000000000..9caed60e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearAnimeDatabaseScreen.kt @@ -0,0 +1,273 @@ +package eu.kanade.presentation.more.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithNonLibraryAnime +import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.domain.animesource.model.AnimeSourceWithCount +import eu.kanade.presentation.animebrowse.components.AnimeSourceIcon +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.util.selectedBackground +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.mi.AnimeDatabase +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ClearAnimeDatabaseScreen : Screen { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val model = rememberScreenModel { ClearAnimeDatabaseScreenModel() } + val state by model.state.collectAsState() + + when (val s = state) { + is ClearAnimeDatabaseScreenModel.State.Loading -> LoadingScreen() + is ClearAnimeDatabaseScreenModel.State.Ready -> { + if (s.showConfirmation) { + AlertDialog( + onDismissRequest = model::hideConfirmation, + confirmButton = { + TextButton( + onClick = { + model.removeAnimeBySourceId() + model.clearSelection() + model.hideConfirmation() + context.toast(R.string.clear_database_completed) + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = model::hideConfirmation) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + text = { + Text(text = stringResource(R.string.clear_database_confirmation)) + }, + ) + } + + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.pref_clear_anime_database), + navigateUp = navigator::pop, + actions = { + if (s.items.isNotEmpty()) { + AppBarActions( + actions = listOf( + AppBar.Action( + title = stringResource(R.string.action_select_all), + icon = Icons.Outlined.SelectAll, + onClick = model::selectAll, + ), + AppBar.Action( + title = stringResource(R.string.action_select_all), + icon = Icons.Outlined.FlipToBack, + onClick = model::invertSelection, + ), + ), + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + if (s.items.isEmpty()) { + EmptyScreen( + message = stringResource(R.string.database_clean), + modifier = Modifier.padding(contentPadding), + ) + } else { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) { + FastScrollLazyColumn( + modifier = Modifier.weight(1f), + ) { + items(s.items) { sourceWithCount -> + ClearDatabaseItem( + source = sourceWithCount.source, + count = sourceWithCount.count, + isSelected = s.selection.contains(sourceWithCount.id), + onClickSelect = { model.toggleSelection(sourceWithCount.source) }, + ) + } + } + + Divider() + + Button( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + onClick = model::showConfirmation, + enabled = s.selection.isNotEmpty(), + ) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } + } + } + } + + @Composable + private fun ClearDatabaseItem( + source: AnimeSource, + count: Long, + isSelected: Boolean, + onClickSelect: () -> Unit, + ) { + Row( + modifier = Modifier + .selectedBackground(isSelected) + .clickable(onClick = onClickSelect) + .padding(horizontal = 8.dp) + .height(56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimeSourceIcon(source = source) + Column( + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + ) { + Text( + text = source.visualName, + style = MaterialTheme.typography.bodyMedium, + ) + Text(text = stringResource(R.string.clear_database_source_item_count, count)) + } + Checkbox( + checked = isSelected, + onCheckedChange = { onClickSelect() }, + ) + } + } +} + +private class ClearAnimeDatabaseScreenModel : StateScreenModel(State.Loading) { + private val getSourcesWithNonLibraryAnime: GetAnimeSourcesWithNonLibraryAnime = Injekt.get() + private val database: AnimeDatabase = Injekt.get() + + init { + coroutineScope.launchIO { + getSourcesWithNonLibraryAnime.subscribe() + .collectLatest { list -> + mutableState.update { old -> + val items = list.sortedBy { it.name } + when (old) { + State.Loading -> State.Ready(items) + is State.Ready -> old.copy(items = items) + } + } + } + } + } + + fun removeAnimeBySourceId() { + val state = state.value as? State.Ready ?: return + database.animesQueries.deleteAnimesNotInLibraryBySourceIds(state.selection) + database.animehistoryQueries.removeResettedHistory() + } + + fun toggleSelection(source: AnimeSource) = mutableState.update { state -> + if (state !is State.Ready) return@update state + val mutableList = state.selection.toMutableList() + if (mutableList.contains(source.id)) { + mutableList.remove(source.id) + } else { + mutableList.add(source.id) + } + state.copy(selection = mutableList) + } + + fun clearSelection() = mutableState.update { state -> + if (state !is State.Ready) return@update state + state.copy(selection = emptyList()) + } + + fun selectAll() = mutableState.update { state -> + if (state !is State.Ready) return@update state + state.copy(selection = state.items.map { it.id }) + } + + fun invertSelection() = mutableState.update { state -> + if (state !is State.Ready) return@update state + state.copy( + selection = state.items + .map { it.id } + .filterNot { it in state.selection }, + ) + } + + fun showConfirmation() = mutableState.update { state -> + if (state !is State.Ready) return@update state + state.copy(showConfirmation = true) + } + + fun hideConfirmation() = mutableState.update { state -> + if (state !is State.Ready) return@update state + state.copy(showConfirmation = false) + } + + sealed class State { + object Loading : State() + data class Ready( + val items: List, + val selection: List = emptyList(), + val showConfirmation: Boolean = false, + ) : State() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index ee8725c1b..ea8d83e79 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -31,6 +31,7 @@ import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.util.collectAsState import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.cache.EpisodeCache import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.track.TrackManager @@ -52,8 +53,6 @@ import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.isPackageInstalled -import eu.kanade.tachiyomi.util.system.isPreviewBuildType -import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.setDefaultSettings @@ -84,7 +83,7 @@ class SettingsAdvancedScreen : SearchableSettings { pref = basePreferences.acraEnabled(), title = stringResource(R.string.pref_enable_acra), subtitle = stringResource(R.string.pref_acra_summary), - enabled = isPreviewBuildType || isReleaseBuildType, + enabled = false, // acra is disabled ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_dump_crash_logs), @@ -159,19 +158,21 @@ class SettingsAdvancedScreen : SearchableSettings { val libraryPreferences = remember { Injekt.get() } val chapterCache = remember { Injekt.get() } + val episodeCache = remember { Injekt.get() } var readableSizeSema by remember { mutableStateOf(0) } val readableSize = remember(readableSizeSema) { chapterCache.readableSize } + val readableAnimeSize = remember(readableSizeSema) { episodeCache.readableSize } return Preference.PreferenceGroup( title = stringResource(R.string.label_data), preferenceItems = listOf( Preference.PreferenceItem.TextPreference( title = stringResource(R.string.pref_clear_chapter_cache), - subtitle = stringResource(R.string.used_cache, readableSize), + subtitle = stringResource(R.string.used_cache_both, readableAnimeSize, readableSize), onClick = { scope.launchNonCancellable { try { - val deletedFiles = chapterCache.clear() + val deletedFiles = chapterCache.clear() + episodeCache.clear() withUIContext { context.toast(context.getString(R.string.cache_deleted, deletedFiles)) readableSizeSema++ @@ -192,6 +193,11 @@ class SettingsAdvancedScreen : SearchableSettings { subtitle = stringResource(R.string.pref_clear_database_summary), onClick = { navigator.push(ClearDatabaseScreen()) }, ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_clear_anime_database), + subtitle = stringResource(R.string.pref_clear_anime_database_summary), + onClick = { navigator.push(ClearAnimeDatabaseScreen()) }, + ), ), ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt index 344720ef8..a921cf7ea 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt @@ -149,10 +149,11 @@ class SettingsBackupScreen : SearchableSettings { ) { val choices = remember { mapOf( - BackupConst.BACKUP_CATEGORY to R.string.categories, - BackupConst.BACKUP_CHAPTER to R.string.chapters, + BackupConst.BACKUP_CATEGORY to R.string.general_categories, + BackupConst.BACKUP_CHAPTER to R.string.chapters_episodes, BackupConst.BACKUP_TRACK to R.string.track, BackupConst.BACKUP_HISTORY to R.string.history, + BackupConst.BACKUP_PREFS to R.string.settings, ) } val flags = remember { choices.keys.toMutableStateList() } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt index f606674be..7811afd3d 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import com.hippo.unifile.UniFile +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.category.interactor.GetAnimeCategories import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.model.Category import eu.kanade.domain.download.service.DownloadPreferences @@ -42,8 +44,12 @@ class SettingsDownloadScreen : SearchableSettings { override fun getPreferences(): List { val getCategories = remember { Injekt.get() } val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() }) + val getAnimeCategories = remember { Injekt.get() } + val allAnimeCategories by getAnimeCategories.subscribe().collectAsState(initial = runBlocking { getAnimeCategories.await() }) val downloadPreferences = remember { Injekt.get() } + val basePreferences = remember { Injekt.get() } + return listOf( getDownloadLocationPreference(downloadPreferences = downloadPreferences), Preference.PreferenceItem.SwitchPreference( @@ -66,8 +72,10 @@ class SettingsDownloadScreen : SearchableSettings { getAutoDownloadGroup( downloadPreferences = downloadPreferences, allCategories = allCategories, + allAnimeCategories = allAnimeCategories, ), getDownloadAheadGroup(downloadPreferences = downloadPreferences), + getExternalDownloaderGroup(downloadPreferences = downloadPreferences, basePreferences = basePreferences), ) } @@ -184,7 +192,34 @@ class SettingsDownloadScreen : SearchableSettings { private fun getAutoDownloadGroup( downloadPreferences: DownloadPreferences, allCategories: List, + allAnimeCategories: List, ): Preference.PreferenceGroup { + val downloadNewEpisodesPref = downloadPreferences.downloadNewEpisodes() + val downloadNewEpisodeCategoriesPref = downloadPreferences.downloadNewEpisodeCategories() + val downloadNewEpisodeCategoriesExcludePref = downloadPreferences.downloadNewEpisodeCategoriesExclude() + + val downloadNewEpisodes by downloadNewEpisodesPref.collectAsState() + + val includedAnime by downloadNewEpisodeCategoriesPref.collectAsState() + val excludedAnime by downloadNewEpisodeCategoriesExcludePref.collectAsState() + var showAnimeDialog by rememberSaveable { mutableStateOf(false) } + if (showAnimeDialog) { + TriStateListDialog( + title = stringResource(R.string.anime_categories), + message = stringResource(R.string.pref_download_new_anime_categories_details), + items = allAnimeCategories, + initialChecked = includedAnime.mapNotNull { id -> allAnimeCategories.find { it.id.toString() == id } }, + initialInversed = excludedAnime.mapNotNull { id -> allAnimeCategories.find { it.id.toString() == id } }, + itemLabel = { it.visualName }, + onDismissRequest = { showAnimeDialog = false }, + onValueChanged = { newIncluded, newExcluded -> + downloadNewEpisodeCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) + downloadNewEpisodeCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet()) + showAnimeDialog = false + }, + ) + } + val downloadNewChaptersPref = downloadPreferences.downloadNewChapters() val downloadNewChapterCategoriesPref = downloadPreferences.downloadNewChapterCategories() val downloadNewChapterCategoriesExcludePref = downloadPreferences.downloadNewChapterCategoriesExclude() @@ -214,6 +249,20 @@ class SettingsDownloadScreen : SearchableSettings { return Preference.PreferenceGroup( title = stringResource(R.string.pref_category_auto_download), preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = downloadNewEpisodesPref, + title = stringResource(R.string.pref_download_new_episodes), + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.anime_categories), + subtitle = getCategoriesLabel( + allCategories = allAnimeCategories, + included = includedAnime, + excluded = excludedAnime, + ), + onClick = { showAnimeDialog = true }, + enabled = downloadNewEpisodes, + ), Preference.PreferenceItem.SwitchPreference( pref = downloadNewChaptersPref, title = stringResource(R.string.pref_download_new), @@ -250,8 +299,58 @@ class SettingsDownloadScreen : SearchableSettings { } }, ), + Preference.PreferenceItem.ListPreference( + pref = downloadPreferences.autoDownloadWhileWatching(), + title = stringResource(R.string.auto_download_while_watching), + entries = listOf(0, 2, 3, 5, 10).associateWith { + if (it == 0) { + stringResource(R.string.disabled) + } else { + pluralStringResource(id = R.plurals.next_unseen_episodes, count = it, it) + } + }, + ), Preference.PreferenceItem.InfoPreference(stringResource(R.string.download_ahead_info)), ), ) } + + @Composable + private fun getExternalDownloaderGroup(downloadPreferences: DownloadPreferences, basePreferences: BasePreferences): Preference.PreferenceGroup { + val useExternalDownloader = downloadPreferences.useExternalDownloader() + val externalDownloaderPreference = downloadPreferences.externalDownloaderSelection() + + val pm = basePreferences.context.packageManager + val installedPackages = pm.getInstalledPackages(0) + val supportedDownloaders = installedPackages.filter { + when (it.packageName) { + "idm.internet.download.manager" -> true + "idm.internet.download.manager.plus" -> true + "idm.internet.download.manager.lite" -> true + else -> false + } + } + val packageNames = supportedDownloaders.map { it.packageName } + val packageNamesReadable = supportedDownloaders + .map { pm.getApplicationLabel(it.applicationInfo).toString() } + + val packageNamesMap: Map = + packageNames.zip(packageNamesReadable) + .toMap() + + return Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_external_downloader), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = useExternalDownloader, + title = stringResource(R.string.pref_use_external_downloader), + ), + Preference.PreferenceItem.ListPreference( + pref = externalDownloaderPreference, + title = stringResource(R.string.pref_external_downloader_selection), + entries = mapOf("" to "None") + packageNamesMap, + ), + ), + ) + } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt index 62fc12a88..4374b18e1 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt @@ -37,6 +37,17 @@ class SettingsGeneralScreen : SearchableSettings { val prefs = remember { Injekt.get() } val libraryPrefs = remember { Injekt.get() } return mutableListOf().apply { + add( + Preference.PreferenceItem.ListPreference( + pref = libraryPrefs.bottomNavStyle(), + title = stringResource(R.string.pref_bottom_nav_style), + entries = mapOf( + 0 to stringResource(R.string.pref_bottom_nav_no_history), + 1 to stringResource(R.string.pref_bottom_nav_no_updates), + 2 to stringResource(R.string.pref_bottom_nav_no_manga), + ), + ), + ) add( Preference.PreferenceItem.SwitchPreference( pref = libraryPrefs.showUpdatesNavBadge(), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 62d4747e6..25156ef5b 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -29,6 +29,7 @@ import androidx.core.content.ContextCompat import cafe.adriel.voyager.navigator.currentOrThrow import com.bluelinelabs.conductor.Router import com.chargemap.compose.numberpicker.NumberPicker +import eu.kanade.domain.category.interactor.GetAnimeCategories import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.ResetCategoryFlags import eu.kanade.domain.category.model.Category @@ -48,6 +49,7 @@ import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.animecategory.AnimeCategoryController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.category.CategoryController import kotlinx.coroutines.launch @@ -65,13 +67,15 @@ class SettingsLibraryScreen : SearchableSettings { @Composable override fun getPreferences(): List { val getCategories = remember { Injekt.get() } - val libraryPreferences = remember { Injekt.get() } val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() }) + val getAnimeCategories = remember { Injekt.get() } + val allAnimeCategories by getAnimeCategories.subscribe().collectAsState(initial = runBlocking { getAnimeCategories.await() }) + val libraryPreferences = remember { Injekt.get() } return mutableListOf( getDisplayGroup(libraryPreferences), getCategoriesGroup(LocalRouter.currentOrThrow, allCategories, libraryPreferences), - getGlobalUpdateGroup(allCategories, libraryPreferences), + getGlobalUpdateGroup(allCategories, allAnimeCategories, libraryPreferences), ) } @@ -121,6 +125,8 @@ class SettingsLibraryScreen : SearchableSettings { val defaultCategory by libraryPreferences.defaultCategory().collectAsState() val selectedCategory = allCategories.find { it.id == defaultCategory.toLong() } + val defaultAnimeCategory by libraryPreferences.defaultAnimeCategory().collectAsState() + val selectedAnimeCategory = allCategories.find { it.id == defaultAnimeCategory.toLong() } // For default category val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) + @@ -129,8 +135,23 @@ class SettingsLibraryScreen : SearchableSettings { allCategories.map { it.visualName(context) } return Preference.PreferenceGroup( - title = stringResource(R.string.categories), + title = stringResource(R.string.general_categories), preferenceItems = listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.action_edit_anime_categories), + subtitle = pluralStringResource( + id = R.plurals.num_categories, + count = userCategoriesCount, + userCategoriesCount, + ), + onClick = { router?.pushController(AnimeCategoryController()) }, + ), + Preference.PreferenceItem.ListPreference( + pref = libraryPreferences.defaultAnimeCategory(), + title = stringResource(R.string.default_anime_category), + subtitle = selectedAnimeCategory?.visualName ?: stringResource(R.string.default_category_summary), + entries = ids.zip(labels).toMap(), + ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.action_edit_categories), subtitle = pluralStringResource( @@ -165,18 +186,42 @@ class SettingsLibraryScreen : SearchableSettings { @Composable private fun getGlobalUpdateGroup( allCategories: List, + allAnimeCategories: List, libraryPreferences: LibraryPreferences, ): Preference.PreferenceGroup { val context = LocalContext.current val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval() + val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState() val libraryUpdateDeviceRestrictionPref = libraryPreferences.libraryUpdateDeviceRestriction() val libraryUpdateMangaRestrictionPref = libraryPreferences.libraryUpdateMangaRestriction() + + val animelibUpdateCategoriesPref = libraryPreferences.animelibUpdateCategories() + val animelibUpdateCategoriesExcludePref = libraryPreferences.animelibUpdateCategoriesExclude() + + val includedAnime by animelibUpdateCategoriesPref.collectAsState() + val excludedAnime by animelibUpdateCategoriesExcludePref.collectAsState() + var showAnimeDialog by rememberSaveable { mutableStateOf(false) } + if (showAnimeDialog) { + TriStateListDialog( + title = stringResource(R.string.anime_categories), + message = stringResource(R.string.pref_animelib_update_categories_details), + items = allAnimeCategories, + initialChecked = includedAnime.mapNotNull { id -> allAnimeCategories.find { it.id.toString() == id } }, + initialInversed = excludedAnime.mapNotNull { id -> allAnimeCategories.find { it.id.toString() == id } }, + itemLabel = { it.visualName }, + onDismissRequest = { showAnimeDialog = false }, + onValueChanged = { newIncluded, newExcluded -> + animelibUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) + animelibUpdateCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet()) + showAnimeDialog = false + }, + ) + } + val libraryUpdateCategoriesPref = libraryPreferences.libraryUpdateCategories() val libraryUpdateCategoriesExcludePref = libraryPreferences.libraryUpdateCategoriesExclude() - val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState() - val included by libraryUpdateCategoriesPref.collectAsState() val excluded by libraryUpdateCategoriesExcludePref.collectAsState() var showDialog by rememberSaveable { mutableStateOf(false) } @@ -241,6 +286,15 @@ class SettingsLibraryScreen : SearchableSettings { MANGA_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed), ), ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.anime_categories), + subtitle = getCategoriesLabel( + allCategories = allAnimeCategories, + included = included, + excluded = excluded, + ), + onClick = { showAnimeDialog = true }, + ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.categories), subtitle = getCategoriesLabel( diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt index 47f11b91d..8ad37f50f 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.GetApp import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.PlayCircleOutline import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.SettingsBackupRestore @@ -215,6 +216,12 @@ private val items = listOf( icon = Icons.Outlined.ChromeReaderMode, screen = SettingsReaderScreen(), ), + Item( + titleRes = R.string.pref_category_player, + subtitleRes = R.string.pref_player_summary, + icon = Icons.Outlined.PlayCircleOutline, + screen = SettingsPlayerScreen(), + ), Item( titleRes = R.string.pref_category_downloads, subtitleRes = R.string.pref_downloads_summary, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt new file mode 100644 index 000000000..ddf5e9795 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt @@ -0,0 +1,328 @@ +package eu.kanade.presentation.more.settings.screen + +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import com.chargemap.compose.numberpicker.NumberPicker +import eu.kanade.domain.base.BasePreferences +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.collectAsState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SettingsPlayerScreen : SearchableSettings { + + @ReadOnlyComposable + @Composable + @StringRes + override fun getTitleRes() = R.string.pref_category_player + + @Composable + override fun getPreferences(): List { + val playerPreferences = remember { Injekt.get() } + val basePreferences = remember { Injekt.get() } + + return listOf( + Preference.PreferenceItem.ListPreference( + pref = playerPreferences.progressPreference(), + title = stringResource(R.string.pref_progress_mark_as_seen), + entries = mapOf( + 1.00F to stringResource(R.string.pref_progress_100), + 0.95F to stringResource(R.string.pref_progress_95), + 0.90F to stringResource(R.string.pref_progress_90), + 0.85F to stringResource(R.string.pref_progress_85), + 0.80F to stringResource(R.string.pref_progress_80), + 0.75F to stringResource(R.string.pref_progress_75), + 0.70F to stringResource(R.string.pref_progress_70), + ), + ), + Preference.PreferenceItem.SwitchPreference( + pref = playerPreferences.preserveWatchingPosition(), + title = stringResource(R.string.pref_preserve_watching_position), + ), + getOrientationGroup(playerPreferences = playerPreferences), + getAniskipGroup(playerPreferences = playerPreferences), + getInternalPlayerGroup(playerPreferences = playerPreferences, basePreferences = basePreferences), + getExternalPlayerGroup(playerPreferences = playerPreferences, basePreferences = basePreferences), + ) + } + + @Composable + private fun getOrientationGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + val defaultPlayerOrientationType = playerPreferences.defaultPlayerOrientationType() + val adjustOrientationVideoDimensions = playerPreferences.adjustOrientationVideoDimensions() + val defaultPlayerOrientationPortrait = playerPreferences.defaultPlayerOrientationPortrait() + val defaultPlayerOrientationLandscape = playerPreferences.defaultPlayerOrientationLandscape() + + return Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_player_orientation), + preferenceItems = listOf( + Preference.PreferenceItem.ListPreference( + pref = defaultPlayerOrientationType, + title = stringResource(R.string.pref_default_player_orientation), + entries = mapOf( + ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR to stringResource(R.string.rotation_free), + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT to stringResource(R.string.rotation_portrait), + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT to stringResource(R.string.rotation_reverse_portrait), + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to stringResource(R.string.rotation_landscape), + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to stringResource(R.string.rotation_reverse_landscape), + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT to stringResource(R.string.rotation_sensor_portrait), + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to stringResource(R.string.rotation_sensor_landscape), + ), + ), + Preference.PreferenceItem.SwitchPreference( + pref = adjustOrientationVideoDimensions, + title = stringResource(R.string.pref_adjust_orientation_video_dimensions), + ), + Preference.PreferenceItem.ListPreference( + pref = defaultPlayerOrientationPortrait, + title = stringResource(R.string.pref_default_portrait_orientation), + entries = mapOf( + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT to stringResource(R.string.rotation_portrait), + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT to stringResource(R.string.rotation_reverse_portrait), + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT to stringResource(R.string.rotation_sensor_portrait), + ), + ), + Preference.PreferenceItem.ListPreference( + pref = defaultPlayerOrientationLandscape, + title = stringResource(R.string.pref_default_landscape_orientation), + entries = mapOf( + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to stringResource(R.string.rotation_landscape), + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to stringResource(R.string.rotation_reverse_landscape), + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to stringResource(R.string.rotation_sensor_landscape), + ), + ), + ), + ) + } + + @Composable + private fun getAniskipGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + val enableAniSkip = playerPreferences.aniSkipEnabled() + val enableAutoAniSkip = playerPreferences.autoSkipAniSkip() + val enableNetflixAniSkip = playerPreferences.enableNetflixStyleAniSkip() + val waitingTimeAniSkip = playerPreferences.waitingTimeAniSkip() + + val isAniSkipEnabled by enableAniSkip.collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_player_aniskip), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = enableAniSkip, + title = stringResource(R.string.pref_enable_aniskip), + ), + Preference.PreferenceItem.SwitchPreference( + pref = enableAutoAniSkip, + title = stringResource(R.string.pref_enable_auto_skip_ani_skip), + enabled = isAniSkipEnabled, + ), + Preference.PreferenceItem.SwitchPreference( + pref = enableNetflixAniSkip, + title = stringResource(R.string.pref_enable_netflix_style_aniskip), + enabled = isAniSkipEnabled, + ), + Preference.PreferenceItem.ListPreference( + pref = waitingTimeAniSkip, + title = stringResource(R.string.pref_waiting_time_aniskip), + entries = mapOf( + 5 to stringResource(R.string.pref_waiting_time_aniskip_5), + 6 to stringResource(R.string.pref_waiting_time_aniskip_6), + 7 to stringResource(R.string.pref_waiting_time_aniskip_7), + 8 to stringResource(R.string.pref_waiting_time_aniskip_8), + 9 to stringResource(R.string.pref_waiting_time_aniskip_9), + 10 to stringResource(R.string.pref_waiting_time_aniskip_10), + ), + ), + ), + ) + } + + @Composable + private fun getInternalPlayerGroup(playerPreferences: PlayerPreferences, basePreferences: BasePreferences): Preference.PreferenceGroup { + val scope = rememberCoroutineScope() + val defaultSkipIntroLength by playerPreferences.skipLengthPreference().stateIn(scope).collectAsState() + val skipLengthPreference = playerPreferences.skipLengthPreference() + val playerSmoothSeek = playerPreferences.playerSmoothSeek() + val playerFullscreen = playerPreferences.playerFullscreen() + val playerHideControls = playerPreferences.hideControls() + val pipEpisodeToasts = playerPreferences.pipEpisodeToasts() + val pipOnExit = playerPreferences.pipOnExit() + val mpvConf = playerPreferences.mpvConf() + + val deviceHasPip = basePreferences.context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + + var showDialog by rememberSaveable { mutableStateOf(false) } + if (showDialog) { + SkipIntroLengthDialog( + initialSkipIntroLength = defaultSkipIntroLength, + onDismissRequest = { showDialog = false }, + onValueChanged = { skipIntroLength -> + playerPreferences.skipLengthPreference().set(skipIntroLength) + showDialog = false + }, + ) + } + + return Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_internal_player), + preferenceItems = listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.pref_default_intro_length), + subtitle = "${defaultSkipIntroLength}s", + onClick = { showDialog = true }, + ), + Preference.PreferenceItem.ListPreference( + pref = skipLengthPreference, + title = stringResource(R.string.pref_skip_length), + entries = mapOf( + 30 to stringResource(R.string.pref_skip_30), + 20 to stringResource(R.string.pref_skip_20), + 10 to stringResource(R.string.pref_skip_10), + 5 to stringResource(R.string.pref_skip_5), + 0 to stringResource(R.string.pref_skip_disable), + ), + ), + Preference.PreferenceItem.SwitchPreference( + pref = playerSmoothSeek, + title = stringResource(R.string.pref_player_smooth_seek), + subtitle = stringResource(R.string.pref_player_smooth_seek_summary), + ), + Preference.PreferenceItem.SwitchPreference( + pref = playerFullscreen, + title = stringResource(R.string.pref_player_fullscreen), + enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P, + ), + Preference.PreferenceItem.SwitchPreference( + pref = playerHideControls, + title = stringResource(R.string.pref_player_hide_controls), + ), + Preference.PreferenceItem.SwitchPreference( + pref = pipEpisodeToasts, + title = stringResource(R.string.pref_pip_episode_toasts), + enabled = deviceHasPip, + ), + Preference.PreferenceItem.SwitchPreference( + pref = pipOnExit, + title = stringResource(R.string.pref_pip_on_exit), + enabled = deviceHasPip, + ), + Preference.PreferenceItem.EditTextPreference( + pref = mpvConf, + title = stringResource(R.string.pref_mpv_conf), + ), + ), + ) + } + + @Composable + private fun getExternalPlayerGroup(playerPreferences: PlayerPreferences, basePreferences: BasePreferences): Preference.PreferenceGroup { + val alwaysUseExternalPlayer = playerPreferences.alwaysUseExternalPlayer() + val externalPlayerPreference = playerPreferences.externalPlayerPreference() + + val pm = basePreferences.context.packageManager + val installedPackages = pm.getInstalledPackages(0) + val supportedPlayers = installedPackages.filter { + when (it.packageName) { + "is.xyz.mpv" -> true + "com.mxtech.videoplayer" -> true + "com.mxtech.videoplayer.ad" -> true + "com.mxtech.videoplayer.pro" -> true + "org.videolan.vlc" -> true + "com.husudosu.mpvremote" -> true + else -> false + } + } + val packageNames = supportedPlayers.map { it.packageName } + val packageNamesReadable = supportedPlayers + .map { pm.getApplicationLabel(it.applicationInfo).toString() } + + val packageNamesMap: Map = + packageNames.zip(packageNamesReadable) + .toMap() + + return Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_external_player), + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + pref = alwaysUseExternalPlayer, + title = stringResource(R.string.pref_always_use_external_player), + ), + Preference.PreferenceItem.ListPreference( + pref = externalPlayerPreference, + title = stringResource(R.string.pref_external_player_preference), + entries = mapOf("" to "None") + packageNamesMap, + ), + ), + ) + } + + @Composable + private fun SkipIntroLengthDialog( + initialSkipIntroLength: Int, + onDismissRequest: () -> Unit, + onValueChanged: (skipIntroLength: Int) -> Unit, + ) { + var skipIntroLengthValue by rememberSaveable { mutableStateOf(initialSkipIntroLength) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.pref_intro_length)) }, + text = { + Row { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + NumberPicker( + modifier = Modifier + .fillMaxWidth() + .clipToBounds(), + value = skipIntroLengthValue, + onValueChange = { skipIntroLengthValue = it }, + range = 1..255, + label = { it.toString() }, + dividersColor = MaterialTheme.colorScheme.primary, + textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { onValueChanged(skipIntroLengthValue) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index fd835cdb5..506b821c6 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -139,6 +139,10 @@ class SettingsReaderScreen : SearchableSettings { pref = readerPreferences.alwaysShowChapterTransition(), title = stringResource(R.string.pref_always_show_chapter_transition), ), + Preference.PreferenceItem.SwitchPreference( + pref = readerPreferences.preserveReadingPosition(), + title = stringResource(R.string.pref_preserve_reading_position), + ), ), ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index 6939777e0..4a87a303b 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt @@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi +import eu.kanade.tachiyomi.data.track.simkl.SimklApi import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.withUIContext @@ -140,6 +141,12 @@ class SettingsTrackingScreen : SearchableSettings { login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) }, logout = { dialog = LogoutDialog(trackManager.shikimori) }, ), + Preference.PreferenceItem.TrackingPreference( + title = stringResource(trackManager.simkl.nameRes()), + service = trackManager.simkl, + login = { context.openInBrowser(SimklApi.authUrl(), forceDefaultBrowser = true) }, + logout = { dialog = LogoutDialog(trackManager.simkl) }, + ), Preference.PreferenceItem.TrackingPreference( title = stringResource(trackManager.bangumi.nameRes()), service = trackManager.bangumi, diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index dc54ff111..4b2c3dcd5 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -33,6 +33,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.recent.UpdatesTabsController.Companion.isCurrentUpdateTabManga import eu.kanade.tachiyomi.ui.recent.updates.UpdatesItem import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter.Dialog @@ -60,6 +61,8 @@ fun UpdateScreen( } BackHandler(onBack = internalOnBackPressed) + isCurrentUpdateTabManga = true + val context = LocalContext.current val onUpdateLibrary = { val started = LibraryUpdateService.start(context) diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 905ab1e84..a0fbbff2d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -24,31 +24,31 @@ import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.disk.DiskCache import coil.util.DebugLogger +import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.data.DatabaseHandler import eu.kanade.domain.DomainModule -import eu.kanade.tachiyomi.data.coil.AnimeCoverFetcher -import eu.kanade.tachiyomi.data.coil.AnimeCoverKeyer -import eu.kanade.tachiyomi.data.coil.AnimeKeyer -import eu.kanade.tachiyomi.data.coil.DomainAnimeKeyer import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.tachiyomi.crash.CrashActivity import eu.kanade.tachiyomi.crash.GlobalExceptionHandler +import eu.kanade.tachiyomi.data.coil.AnimeCoverFetcher +import eu.kanade.tachiyomi.data.coil.AnimeCoverKeyer +import eu.kanade.tachiyomi.data.coil.AnimeKeyer +import eu.kanade.tachiyomi.data.coil.DomainAnimeKeyer import eu.kanade.tachiyomi.data.coil.DomainMangaKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.glance.AnimeUpdatesGridGlanceWidget import eu.kanade.tachiyomi.glance.UpdatesGridGlanceWidget import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.animatorDurationScale -import eu.kanade.tachiyomi.util.system.isPreviewBuildType -import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.notification import kotlinx.coroutines.flow.distinctUntilChanged @@ -140,6 +140,18 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { } .launchIn(ProcessLifecycleOwner.get().lifecycleScope) + Injekt.get() + .subscribeToList { animeupdatesViewQueries.animeupdates(after = AnimeUpdatesGridGlanceWidget.DateLimit.timeInMillis) } + .drop(1) + .distinctUntilChanged() + .onEach { + val manager = GlanceAppWidgetManager(this) + if (manager.getGlanceIds(AnimeUpdatesGridGlanceWidget::class.java).isNotEmpty()) { + AnimeUpdatesGridGlanceWidget().loadData(it) + } + } + .launchIn(ProcessLifecycleOwner.get().lifecycleScope) + if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) { LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE)) } @@ -166,8 +178,8 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { add(MangaCoverFetcher.MangaCoverFactory(lazy(callFactoryInit), lazy(diskCacheInit))) add(MangaKeyer()) add(DomainMangaKeyer()) - add(MangaCoverKeyer()) add(AnimeCoverKeyer()) + add(MangaCoverKeyer()) } callFactory(callFactoryInit) diskCache(diskCacheInit) diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 4fe88a647..0360e2058 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -6,6 +6,7 @@ import androidx.core.content.ContextCompat import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import com.squareup.sqldelight.android.AndroidSqliteDriver +import com.squareup.sqldelight.db.SqlDriver import data.History import data.Mangas import dataanime.Animehistory @@ -16,12 +17,6 @@ import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.data.DatabaseHandler import eu.kanade.data.dateAdapter import eu.kanade.data.listOfStringsAdapter -import eu.kanade.tachiyomi.animesource.AnimeSourceManager -import eu.kanade.tachiyomi.data.cache.AnimeCoverCache -import eu.kanade.tachiyomi.data.cache.ChapterCache -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.cache.EpisodeCache -import eu.kanade.tachiyomi.data.download.AnimeDownloadManager import eu.kanade.data.updateStrategyAdapter import eu.kanade.domain.backup.service.BackupPreferences import eu.kanade.domain.base.BasePreferences @@ -30,24 +25,33 @@ import eu.kanade.domain.library.service.LibraryPreferences import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.core.preference.AndroidPreferenceStore import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.provider.AndroidBackupFolderProvider import eu.kanade.tachiyomi.core.provider.AndroidDownloadFolderProvider import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadCache +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadProvider +import eu.kanade.tachiyomi.data.cache.AnimeCoverCache +import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.cache.EpisodeCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore -import eu.kanade.tachiyomi.extension.AnimeExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.mi.AnimeDatabase import eu.kanade.tachiyomi.network.JavaScriptEngine import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.system.isDevFlavor import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory @@ -120,7 +124,7 @@ class AppModule(val app: Application) : InjektModule { } addSingletonFactory { Database( - driver = sqlDriverManga, + driver = get(), historyAdapter = History.Adapter( last_readAdapter = dateAdapter, ), @@ -133,19 +137,20 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { AnimeDatabase( - driver = sqlDriverAnime, + driver = get(), animehistoryAdapter = Animehistory.Adapter( last_seenAdapter = dateAdapter, ), animesAdapter = Animes.Adapter( genreAdapter = listOfStringsAdapter, + update_strategyAdapter = updateStrategyAdapter, ), ) } - addSingletonFactory { AndroidDatabaseHandler(get(), sqlDriverManga) } + addSingletonFactory { AndroidDatabaseHandler(get(), get()) } - addSingletonFactory { AndroidAnimeDatabaseHandler(get(), sqlDriverAnime) } + addSingletonFactory { AndroidAnimeDatabaseHandler(get(), get()) } addSingletonFactory { Json { @@ -161,11 +166,9 @@ class AppModule(val app: Application) : InjektModule { } addSingletonFactory { ChapterCache(app) } - addSingletonFactory { EpisodeCache(app) } addSingletonFactory { CoverCache(app) } - addSingletonFactory { AnimeCoverCache(app) } addSingletonFactory { NetworkHelper(app) } @@ -177,11 +180,13 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { ExtensionManager(app) } addSingletonFactory { AnimeExtensionManager(app) } + addSingletonFactory { DownloadProvider(app) } addSingletonFactory { DownloadManager(app) } addSingletonFactory { DownloadCache(app) } addSingletonFactory { AnimeDownloadProvider(app) } addSingletonFactory { AnimeDownloadManager(app) } + addSingletonFactory { AnimeDownloadCache(app) } addSingletonFactory { TrackManager(app) } addSingletonFactory { DelayedTrackingStore(app) } @@ -227,6 +232,9 @@ class PreferenceModule(val application: Application) : InjektModule { addSingletonFactory { ReaderPreferences(get()) } + addSingletonFactory { + PlayerPreferences(get()) + } addSingletonFactory { TrackPreferences(get()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 860e0daec..392789e2d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -4,21 +4,21 @@ import android.content.Context import android.os.Build import androidx.core.content.edit import androidx.preference.PreferenceManager -import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateJob import eu.kanade.domain.backup.service.BackupPreferences import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.library.service.LibraryPreferences import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.ui.UiPreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionUpdateJob import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateJob import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.AppUpdateJob -import eu.kanade.tachiyomi.extension.AnimeExtensionUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE @@ -216,9 +216,9 @@ object Migrations { libraryPreferences.libraryUpdateInterval().set(3) LibraryUpdateJob.setupTask(context, 3) } - val animeupdateInterval = preferences.libraryUpdateInterval().get() + val animeupdateInterval = libraryPreferences.libraryUpdateInterval().get() if (animeupdateInterval == 1 || animeupdateInterval == 2) { - preferences.libraryUpdateInterval().set(3) + libraryPreferences.libraryUpdateInterval().set(3) AnimelibUpdateJob.setupTask(context, 3) } } @@ -339,6 +339,12 @@ object Migrations { libraryPreferences.sortChapterBySourceOrNumber(), libraryPreferences.displayChapterByNameOrNumber(), libraryPreferences.sortChapterByAscendingOrDescending(), + libraryPreferences.filterEpisodeBySeen(), + libraryPreferences.filterEpisodeByDownloaded(), + libraryPreferences.filterEpisodeByBookmarked(), + libraryPreferences.sortEpisodeBySourceOrNumber(), + libraryPreferences.displayEpisodeByNameOrNumber(), + libraryPreferences.sortEpisodeByAscendingOrDescending(), ) prefs.edit { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt similarity index 59% rename from app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionManager.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt index e1a1e0d80..4989819e4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt @@ -1,24 +1,25 @@ -package eu.kanade.tachiyomi.extension +package eu.kanade.tachiyomi.animeextension import android.content.Context import android.graphics.drawable.Drawable -import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.animesource.AnimeSource -import eu.kanade.tachiyomi.animesource.AnimeSourceManager -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.api.AnimeExtensionGithubApi -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.api.AnimeExtensionGithubApi +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionInstallReceiver +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionInstaller +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionLoader import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.extension.util.AnimeExtensionInstallReceiver -import eu.kanade.tachiyomi.extension.util.AnimeExtensionInstaller -import eu.kanade.tachiyomi.extension.util.AnimeExtensionLoader import eu.kanade.tachiyomi.util.lang.launchNow +import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.preference.plusAssign import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import logcat.LogPriority import rx.Observable import uy.kohesive.injekt.Injekt @@ -36,9 +37,12 @@ import uy.kohesive.injekt.api.get */ class AnimeExtensionManager( private val context: Context, - private val preferences: PreferencesHelper = Injekt.get(), + private val preferences: SourcePreferences = Injekt.get(), ) { + var isInitialized = false + private set + /** * API where all the available extensions can be found. */ @@ -49,131 +53,67 @@ class AnimeExtensionManager( */ private val installer by lazy { AnimeExtensionInstaller(context) } - /** - * Relay used to notify the installed extensions. - */ - private val installedExtensionsRelay = BehaviorRelay.create>() - private val iconMap = mutableMapOf() - /** - * List of the currently installed extensions. - */ - var installedExtensions = emptyList() - private set(value) { - field = value - installedExtensionsRelay.call(value) - } + private val _installedExtensionsFlow = MutableStateFlow(emptyList()) + val installedExtensionsFlow = _installedExtensionsFlow.asStateFlow() - fun getAppIconForSource(source: AnimeSource): Drawable? { - return getAppIconForSource(source.id) - } + private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet() fun getAppIconForSource(sourceId: Long): Drawable? { - val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName + val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName if (pkgName != null) { return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } } return null } - /** - * Relay used to notify the available extensions. - */ - private val availableExtensionsRelay = BehaviorRelay.create>() + private val _availableExtensionsFlow = MutableStateFlow(emptyList()) + val availableExtensionsFlow = _availableExtensionsFlow.asStateFlow() - /** - * List of the currently available extensions. - */ - var availableExtensions = emptyList() - private set(value) { - field = value - availableExtensionsRelay.call(value) - updatedInstalledExtensionsStatuses(value) - } + private var availableExtensionsSourcesData: Map = mapOf() - /** - * Relay used to notify the untrusted extensions. - */ - private val untrustedExtensionsRelay = BehaviorRelay.create>() + fun getAnimeSourceData(id: Long) = availableExtensionsSourcesData[id] - /** - * List of the currently untrusted extensions. - */ - var untrustedExtensions = emptyList() - private set(value) { - field = value - untrustedExtensionsRelay.call(value) - } + private val _untrustedExtensionsFlow = MutableStateFlow(emptyList()) + val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow() - /** - * The source manager where the sources of the extensions are added. - */ - private lateinit var sourceManager: AnimeSourceManager - - /** - * Initializes this manager with the given source manager. - */ - fun init(sourceManager: AnimeSourceManager) { - this.sourceManager = sourceManager - initExtensions() + init { + initAnimeExtensions() AnimeExtensionInstallReceiver(AnimeInstallationListener()).register(context) } /** * Loads and registers the installed extensions. */ - private fun initExtensions() { + private fun initAnimeExtensions() { val extensions = AnimeExtensionLoader.loadExtensions(context) - installedExtensions = extensions + _installedExtensionsFlow.value = extensions .filterIsInstance() .map { it.extension } - installedExtensions - .flatMap { it.sources } - .forEach { sourceManager.registerSource(it) } - untrustedExtensions = extensions + _untrustedExtensionsFlow.value = extensions .filterIsInstance() .map { it.extension } - } - /** - * Returns the relay of the installed extensions as an observable. - */ - fun getInstalledExtensionsObservable(): Observable> { - return installedExtensionsRelay.asObservable() - } - - /** - * Returns the relay of the available extensions as an observable. - */ - fun getAvailableExtensionsObservable(): Observable> { - return availableExtensionsRelay.asObservable() - } - - /** - * Returns the relay of the untrusted extensions as an observable. - */ - fun getUntrustedExtensionsObservable(): Observable> { - return untrustedExtensionsRelay.asObservable() + isInitialized = true } /** * Finds the available extensions in the [api] and updates [availableExtensions]. */ - fun findAvailableExtensions() { - launchNow { - val extensions: List = try { - api.findExtensions() - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - context.toast(R.string.extension_api_error) - emptyList() - } - - availableExtensions = extensions + suspend fun findAvailableExtensions() { + val extensions: List = try { + api.findExtensions() + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + withUIContext { context.toast(R.string.extension_api_error) } + emptyList() } + + _availableExtensionsFlow.value = extensions + updatedInstalledExtensionsStatuses(extensions) } /** @@ -187,18 +127,19 @@ class AnimeExtensionManager( return } - val mutInstalledExtensions = installedExtensions.toMutableList() + val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList() var changed = false for ((index, installedExt) in mutInstalledExtensions.withIndex()) { val pkgName = installedExt.pkgName val availableExt = availableExtensions.find { it.pkgName == pkgName } - if (availableExt == null && !installedExt.isObsolete) { + if (!installedExt.isUnofficial && availableExt == null && !installedExt.isObsolete) { mutInstalledExtensions[index] = installedExt.copy(isObsolete = true) changed = true } else if (availableExt != null) { - val hasUpdate = availableExt.versionCode > installedExt.versionCode + val hasUpdate = installedExt.updateExists(availableExt) + if (installedExt.hasUpdate != hasUpdate) { mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate) changed = true @@ -206,7 +147,7 @@ class AnimeExtensionManager( } } if (changed) { - installedExtensions = mutInstalledExtensions + _installedExtensionsFlow.value = mutInstalledExtensions } updatePendingUpdatesCount() } @@ -230,7 +171,7 @@ class AnimeExtensionManager( * @param extension The extension to be updated. */ fun updateExtension(extension: AnimeExtension.Installed): Observable { - val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } + val availableExt = _availableExtensionsFlow.value.find { it.pkgName == extension.pkgName } ?: return Observable.empty() return installExtension(availableExt) } @@ -268,14 +209,14 @@ class AnimeExtensionManager( * @param signature The signature to whitelist. */ fun trustSignature(signature: String) { - val untrustedSignatures = untrustedExtensions.map { it.signatureHash }.toSet() + val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet() if (signature !in untrustedSignatures) return AnimeExtensionLoader.trustedSignatures += signature preferences.trustedSignatures() += signature - val nowTrustedExtensions = untrustedExtensions.filter { it.signatureHash == signature } - untrustedExtensions -= nowTrustedExtensions + val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature } + _untrustedExtensionsFlow.value -= nowTrustedExtensions val ctx = context launchNow { @@ -298,8 +239,7 @@ class AnimeExtensionManager( * @param extension The extension to be registered. */ private fun registerNewExtension(extension: AnimeExtension.Installed) { - installedExtensions += extension - extension.sources.forEach { sourceManager.registerSource(it) } + _installedExtensionsFlow.value += extension } /** @@ -309,15 +249,12 @@ class AnimeExtensionManager( * @param extension The extension to be registered. */ private fun registerUpdatedExtension(extension: AnimeExtension.Installed) { - val mutInstalledExtensions = installedExtensions.toMutableList() + val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList() val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName } if (oldExtension != null) { mutInstalledExtensions -= oldExtension - extension.sources.forEach { sourceManager.unregisterSource(it) } } mutInstalledExtensions += extension - installedExtensions = mutInstalledExtensions - extension.sources.forEach { sourceManager.registerSource(it) } } /** @@ -327,14 +264,13 @@ class AnimeExtensionManager( * @param pkgName The package name of the uninstalled application. */ private fun unregisterExtension(pkgName: String) { - val installedExtension = installedExtensions.find { it.pkgName == pkgName } + val installedExtension = _installedExtensionsFlow.value.find { it.pkgName == pkgName } if (installedExtension != null) { - installedExtensions -= installedExtension - installedExtension.sources.forEach { sourceManager.unregisterSource(it) } + _installedExtensionsFlow.value -= installedExtension } - val untrustedExtension = untrustedExtensions.find { it.pkgName == pkgName } + val untrustedExtension = _untrustedExtensionsFlow.value.find { it.pkgName == pkgName } if (untrustedExtension != null) { - untrustedExtensions -= untrustedExtension + _untrustedExtensionsFlow.value -= untrustedExtension } } @@ -354,7 +290,7 @@ class AnimeExtensionManager( } override fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) { - untrustedExtensions += extension + _untrustedExtensionsFlow.value += extension } override fun onPackageUninstalled(pkgName: String) { @@ -367,14 +303,21 @@ class AnimeExtensionManager( * Extension method to set the update field of an installed extension. */ private fun AnimeExtension.Installed.withUpdateCheck(): AnimeExtension.Installed { - val availableExt = availableExtensions.find { it.pkgName == pkgName } - if (availableExt != null && availableExt.versionCode > versionCode) { - return copy(hasUpdate = true) + return if (updateExists()) { + copy(hasUpdate = true) + } else { + this } - return this + } + + private fun AnimeExtension.Installed.updateExists(availableExtension: AnimeExtension.Available? = null): Boolean { + val availableExt = availableExtension ?: _availableExtensionsFlow.value.find { it.pkgName == pkgName } + if (isUnofficial || availableExt == null) return false + + return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) } private fun updatePendingUpdatesCount() { - preferences.animeextensionUpdatesCount().set(installedExtensions.count { it.hasUpdate }) + preferences.animeextensionUpdatesCount().set(_installedExtensionsFlow.value.count { it.hasUpdate }) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionUpdateJob.kt similarity index 93% rename from app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionUpdateJob.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionUpdateJob.kt index d07859086..57ba47c22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/AnimeExtensionUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionUpdateJob.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension +package eu.kanade.tachiyomi.animeextension import android.content.Context import androidx.core.app.NotificationCompat @@ -10,11 +10,11 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.api.AnimeExtensionGithubApi import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.api.AnimeExtensionGithubApi import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.notification import kotlinx.coroutines.coroutineScope @@ -68,7 +68,7 @@ class AnimeExtensionUpdateJob(private val context: Context, workerParams: Worker private const val TAG = "ExtensionUpdate" fun setupTask(context: Context, forceAutoUpdateJob: Boolean? = null) { - val preferences = Injekt.get() + val preferences = Injekt.get() val autoUpdateJob = forceAutoUpdateJob ?: preferences.automaticExtUpdates().get() if (autoUpdateJob) { val constraints = Constraints.Builder() diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionGithubApi.kt similarity index 64% rename from app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionGithubApi.kt index 242ce62dc..ba04c7040 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionGithubApi.kt @@ -1,10 +1,12 @@ -package eu.kanade.tachiyomi.extension.api +package eu.kanade.tachiyomi.animeextension.api import android.content.Context -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.AnimeLoadResult -import eu.kanade.tachiyomi.extension.util.AnimeExtensionLoader +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionLoader +import eu.kanade.tachiyomi.core.preference.Preference +import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.await @@ -20,20 +22,29 @@ import java.util.concurrent.TimeUnit internal class AnimeExtensionGithubApi { private val networkService: NetworkHelper by injectLazy() - private val preferences: PreferencesHelper by injectLazy() + private val preferenceStore: PreferenceStore by injectLazy() + private val lastExtCheck: Preference by lazy { + preferenceStore.getLong("last_ext_check", 0) + } + + private val animeExtensionManager: AnimeExtensionManager by injectLazy() private var requiresFallbackSource = false suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) null else try { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .await() - } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } - requiresFallbackSource = true + val githubResponse = if (requiresFallbackSource) { null + } else { + try { + networkService.client + .newCall(GET("${REPO_URL_PREFIX}index.min.json")) + .await() + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + requiresFallbackSource = true + null + } } val response = githubResponse ?: run { @@ -56,13 +67,17 @@ internal class AnimeExtensionGithubApi { } } - suspend fun checkForUpdates(context: Context): List? { + suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List? { // Limit checks to once a day at most - if (Date().time < preferences.lastAnimeExtCheck().get() + TimeUnit.DAYS.toMillis(1)) { + if (fromAvailableExtensionList.not() && Date().time < lastExtCheck.get() + TimeUnit.DAYS.toMillis(1)) { return null } - val extensions = findExtensions().also { preferences.lastAnimeExtCheck().set(Date().time) } + val extensions = if (fromAvailableExtensionList) { + animeExtensionManager.availableExtensionsFlow.value + } else { + findExtensions().also { lastExtCheck.set(Date().time) } + } val installedExtensions = AnimeExtensionLoader.loadExtensions(context) .filterIsInstance() @@ -73,7 +88,7 @@ internal class AnimeExtensionGithubApi { val pkgName = installedExt.pkgName val availableExt = extensions.find { it.pkgName == pkgName } ?: continue - val hasUpdate = availableExt.versionCode > installedExt.versionCode + val hasUpdate = installedExt.isUnofficial.not() && (availableExt.versionCode > installedExt.versionCode) if (hasUpdate) { extensionsWithUpdate.add(installedExt) } @@ -85,7 +100,7 @@ internal class AnimeExtensionGithubApi { private fun List.toExtensions(): List { return this .filter { - val libVersion = it.version.substringBeforeLast('.').toDouble() + val libVersion = it.extractLibVersion() libVersion >= AnimeExtensionLoader.LIB_VERSION_MIN && libVersion <= AnimeExtensionLoader.LIB_VERSION_MAX } .map { @@ -94,6 +109,7 @@ internal class AnimeExtensionGithubApi { pkgName = it.pkg, versionName = it.version, versionCode = it.code, + libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, // need to implement new extension stuff @@ -118,6 +134,10 @@ internal class AnimeExtensionGithubApi { } } +private fun AnimeExtensionJsonObject.extractLibVersion(): Double { + return version.substringBeforeLast('.').toDouble() +} + private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/jmir1/aniyomi-extensions/repo/" private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/jmir1/aniyomi-extensions@repo/" diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/InstallerAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/InstallerAnime.kt similarity index 97% rename from app/src/main/java/eu/kanade/tachiyomi/extension/installer/InstallerAnime.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/InstallerAnime.kt index dfa4bec1d..dd7a64ecc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/InstallerAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/InstallerAnime.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.installer +package eu.kanade.tachiyomi.animeextension.installer import android.app.Service import android.content.BroadcastReceiver @@ -8,7 +8,7 @@ import android.content.IntentFilter import android.net.Uri import androidx.annotation.CallSuper import androidx.localbroadcastmanager.content.LocalBroadcastManager -import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager import eu.kanade.tachiyomi.extension.model.InstallStep import uy.kohesive.injekt.injectLazy import java.util.Collections diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstallerAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/PackageInstallerInstallerAnime.kt similarity index 95% rename from app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstallerAnime.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/PackageInstallerInstallerAnime.kt index 238f654ad..2ac76f86f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstallerAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/PackageInstallerInstallerAnime.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.installer +package eu.kanade.tachiyomi.animeextension.installer import android.app.PendingIntent import android.app.Service @@ -10,6 +10,7 @@ import android.content.pm.PackageInstaller import android.os.Build import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.lang.use +import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getUriSize import eu.kanade.tachiyomi.util.system.logcat import logcat.LogPriority @@ -22,7 +23,7 @@ class PackageInstallerInstallerAnime(private val service: Service) : InstallerAn override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT) if (userAction == null) { logcat(LogPriority.ERROR) { "Fatal error for $intent" } continueQueue(InstallStep.Error) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstallerAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/ShizukuInstallerAnime.kt similarity index 98% rename from app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstallerAnime.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/ShizukuInstallerAnime.kt index e27f996bc..0e54a48d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstallerAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/installer/ShizukuInstallerAnime.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.installer +package eu.kanade.tachiyomi.animeextension.installer import android.app.Service import android.content.pm.PackageManager diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt index b8f9431db..40b498e44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.model +package eu.kanade.tachiyomi.animeextension.model import android.graphics.drawable.Drawable import eu.kanade.domain.animesource.model.AnimeSourceData @@ -10,6 +10,7 @@ sealed class AnimeExtension { abstract val pkgName: String abstract val versionName: String abstract val versionCode: Long + abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean abstract val hasReadme: Boolean @@ -20,6 +21,7 @@ sealed class AnimeExtension { override val pkgName: String, override val versionName: String, override val versionCode: Long, + override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, override val hasReadme: Boolean, @@ -37,6 +39,7 @@ sealed class AnimeExtension { override val pkgName: String, override val versionName: String, override val versionCode: Long, + override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, override val hasReadme: Boolean, @@ -50,6 +53,7 @@ sealed class AnimeExtension { override val pkgName: String, override val versionName: String, override val versionCode: Long, + override val libVersion: Double, val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeLoadResult.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeLoadResult.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt index a1ec78b28..32176fdfa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeLoadResult.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt @@ -1,10 +1,7 @@ -package eu.kanade.tachiyomi.extension.model +package eu.kanade.tachiyomi.animeextension.model sealed class AnimeLoadResult { - class Success(val extension: AnimeExtension.Installed) : AnimeLoadResult() class Untrusted(val extension: AnimeExtension.Untrusted) : AnimeLoadResult() - class Error(val message: String? = null) : AnimeLoadResult() { - constructor(exception: Throwable) : this(exception.message) - } + object Error : AnimeLoadResult() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallActivity.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallActivity.kt index 11511ed21..e23c1569c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallActivity.kt @@ -1,9 +1,9 @@ -package eu.kanade.tachiyomi.extension.util +package eu.kanade.tachiyomi.animeextension.util import android.app.Activity import android.content.Intent import android.os.Bundle -import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.system.toast import uy.kohesive.injekt.Injekt diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt similarity index 79% rename from app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt index ec8e8e26e..aa910e169 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt @@ -1,16 +1,18 @@ -package eu.kanade.tachiyomi.extension.util +package eu.kanade.tachiyomi.animeextension.util import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult import eu.kanade.tachiyomi.util.lang.launchNow +import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async +import logcat.LogPriority /** * Broadcast receiver that listens for the system's packages installed, updated or removed, and only @@ -48,11 +50,13 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : when (intent.action) { Intent.ACTION_PACKAGE_ADDED -> { - if (!isReplacing(intent)) launchNow { - when (val result = getExtensionFromIntent(context, intent)) { - is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension) - is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) - else -> {} + if (!isReplacing(intent)) { + launchNow { + when (val result = getExtensionFromIntent(context, intent)) { + is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension) + is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + else -> {} + } } } } @@ -94,8 +98,16 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : */ private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): AnimeLoadResult { val pkgName = getPackageNameFromIntent(intent) - ?: return AnimeLoadResult.Error("Package name not found") - return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { AnimeExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await() + if (pkgName == null) { + logcat(LogPriority.WARN) { "Package name not found" } + return AnimeLoadResult.Error + } + return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { + AnimeExtensionLoader.loadExtensionFromPkgName( + context, + pkgName, + ) + }.await() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallService.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallService.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallService.kt index 16a66681b..0e4d2183a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallService.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.util +package eu.kanade.tachiyomi.animeextension.util import android.app.Service import android.content.Context @@ -6,12 +6,13 @@ import android.content.Intent import android.net.Uri import android.os.IBinder import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.installer.InstallerAnime +import eu.kanade.tachiyomi.animeextension.installer.PackageInstallerInstallerAnime +import eu.kanade.tachiyomi.animeextension.installer.ShizukuInstallerAnime +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferenceValues -import eu.kanade.tachiyomi.extension.installer.InstallerAnime -import eu.kanade.tachiyomi.extension.installer.PackageInstallerInstallerAnime -import eu.kanade.tachiyomi.extension.installer.ShizukuInstallerAnime -import eu.kanade.tachiyomi.extension.util.AnimeExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID +import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.notificationBuilder import logcat.LogPriority @@ -36,7 +37,7 @@ class AnimeExtensionInstallService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val uri = intent?.data val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L } - val installerUsed = intent?.getSerializableExtra(EXTRA_INSTALLER) as? PreferenceValues.ExtensionInstaller + val installerUsed = intent?.getSerializableExtraCompat(EXTRA_INSTALLER) if (uri == null || id == null || installerUsed == null) { stopSelf() return START_NOT_STICKY diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstaller.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt index 593cba83c..524738164 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.util +package eu.kanade.tachiyomi.animeextension.util import android.app.DownloadManager import android.content.BroadcastReceiver @@ -11,10 +11,10 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.domain.base.BasePreferences +import eu.kanade.tachiyomi.animeextension.installer.InstallerAnime +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension import eu.kanade.tachiyomi.data.preference.PreferenceValues -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.installer.InstallerAnime -import eu.kanade.tachiyomi.extension.model.AnimeExtension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.logcat @@ -54,7 +54,7 @@ internal class AnimeExtensionInstaller(private val context: Context) { */ private val downloadsRelay = PublishRelay.create>() - private val installerPref = Injekt.get().extensionInstaller() + private val extensionInstaller = Injekt.get().extensionInstaller() /** * Adds the given extension to the downloads queue and returns an observable containing its @@ -133,7 +133,7 @@ internal class AnimeExtensionInstaller(private val context: Context) { * @param uri The uri of the extension to install. */ fun installApk(downloadId: Long, uri: Uri) { - when (val installer = installerPref.get()) { + when (val installer = extensionInstaller.get()) { PreferenceValues.ExtensionInstaller.LEGACY -> { val intent = Intent(context, AnimeExtensionInstallActivity::class.java) .setDataAndType(uri, APK_MIME) @@ -143,7 +143,8 @@ internal class AnimeExtensionInstaller(private val context: Context) { context.startActivity(intent) } else -> { - val intent = AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer) + val intent = + AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer) ContextCompat.startForegroundService(context, intent) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionLoader.kt rename to app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt index 86aa7345b..825f35548 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt @@ -1,17 +1,18 @@ -package eu.kanade.tachiyomi.extension.util +package eu.kanade.tachiyomi.animeextension.util import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.os.Build import androidx.core.content.pm.PackageInfoCompat import dalvik.system.PathClassLoader +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.AnimeSourceFactory -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.AnimeLoadResult import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.system.getApplicationIcon import eu.kanade.tachiyomi.util.system.logcat @@ -26,7 +27,7 @@ import uy.kohesive.injekt.injectLazy @SuppressLint("PackageManagerGetSignatures") internal object AnimeExtensionLoader { - private val preferences: PreferencesHelper by injectLazy() + private val preferences: SourcePreferences by injectLazy() private val loadNsfwSource by lazy { preferences.showNsfwSource().get() } @@ -57,8 +58,16 @@ internal object AnimeExtensionLoader { */ fun loadExtensions(context: Context): List { val pkgManager = context.packageManager - val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS) + + @Suppress("DEPRECATION") + val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong())) + } else { + pkgManager.getInstalledPackages(PACKAGE_FLAGS) + } + val extPkgs = installedPkgs.filter { isPackageAnExtension(it) } + if (extPkgs.isEmpty()) return emptyList() // Load each extension concurrently and wait for completion @@ -79,10 +88,12 @@ internal object AnimeExtensionLoader { context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - return AnimeLoadResult.Error(error) + logcat(LogPriority.ERROR, error) + return AnimeLoadResult.Error } if (!isPackageAnExtension(pkgInfo)) { - return AnimeLoadResult.Error("Tried to load a package that wasn't a extension") + logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } + return AnimeLoadResult.Error } return loadExtension(context, pkgName, pkgInfo) } @@ -101,7 +112,8 @@ internal object AnimeExtensionLoader { pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - return AnimeLoadResult.Error(error) + logcat(LogPriority.ERROR, error) + return AnimeLoadResult.Error } val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ") @@ -109,35 +121,35 @@ internal object AnimeExtensionLoader { val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) if (versionName.isNullOrEmpty()) { - val exception = Exception("Missing versionName for extension $extName") - logcat(LogPriority.WARN, exception) - return AnimeLoadResult.Error(exception) + logcat(LogPriority.WARN) { "Missing versionName for extension $extName" } + return AnimeLoadResult.Error } // Validate lib version val libVersion = versionName.substringBeforeLast('.').toDouble() if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { - val exception = Exception( + logcat(LogPriority.WARN) { "Lib version is $libVersion, while only versions " + - "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed", - ) - logcat(LogPriority.WARN, exception) - return AnimeLoadResult.Error(exception) + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" + } + return AnimeLoadResult.Error } val signatureHash = getSignatureHash(pkgInfo) if (signatureHash == null) { - return AnimeLoadResult.Error("Package $pkgName isn't signed") + logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } + return AnimeLoadResult.Error } else if (signatureHash !in trustedSignatures) { - val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash) + val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" }) return AnimeLoadResult.Untrusted(extension) } val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 if (!loadNsfwSource && isNsfw) { - return AnimeLoadResult.Error("NSFW extension $pkgName not allowed") + logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" } + return AnimeLoadResult.Error } val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1 @@ -164,7 +176,7 @@ internal object AnimeExtensionLoader { } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } - return AnimeLoadResult.Error(e) + return AnimeLoadResult.Error } } @@ -178,14 +190,15 @@ internal object AnimeExtensionLoader { } val extension = AnimeExtension.Installed( - extName, - pkgName, - versionName, - versionCode, - lang, - isNsfw, - hasReadme, - hasChangelog, + name = extName, + pkgName = pkgName, + versionName = versionName, + versionCode = versionCode, + libVersion = libVersion, + lang = lang, + isNsfw = isNsfw, + hasReadme = hasReadme, + hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), isUnofficial = signatureHash != officialSignature, diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSource.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSource.kt deleted file mode 100644 index bf4df9bdd..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSource.kt +++ /dev/null @@ -1,124 +0,0 @@ -package eu.kanade.tachiyomi.animesource - -import android.graphics.drawable.Drawable -import eu.kanade.domain.animesource.model.AnimeSourceData -import eu.kanade.tachiyomi.animesource.model.SAnime -import eu.kanade.tachiyomi.animesource.model.SEpisode -import eu.kanade.tachiyomi.animesource.model.Video -import eu.kanade.tachiyomi.animesource.model.toAnimeInfo -import eu.kanade.tachiyomi.animesource.model.toEpisodeInfo -import eu.kanade.tachiyomi.animesource.model.toSAnime -import eu.kanade.tachiyomi.animesource.model.toSEpisode -import eu.kanade.tachiyomi.animesource.model.toVideoUrl -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.util.lang.awaitSingle -import rx.Observable -import tachiyomi.animesource.model.AnimeInfo -import tachiyomi.animesource.model.EpisodeInfo -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -/** - * A basic interface for creating a source. It could be an online source, a local source, etc... - */ -interface AnimeSource : tachiyomi.animesource.AnimeSource { - - /** - * Id for the source. Must be unique. - */ - override val id: Long - - /** - * Name of the source. - */ - override val name: String - - override val lang: String - get() = "" - - /** - * Returns an observable with the updated details for a anime. - * - * @param anime the anime to update. - */ - @Deprecated( - "Use the 1.x API instead", - ReplaceWith("getAnimeDetails"), - ) - fun fetchAnimeDetails(anime: SAnime): Observable = throw IllegalStateException("Not used") - - /** - * Returns an observable with all the available episodes for an anime. - * - * @param anime the anime to update. - */ - @Deprecated( - "Use the 1.x API instead", - ReplaceWith("getEpisodeList"), - ) - fun fetchEpisodeList(anime: SAnime): Observable> = throw IllegalStateException("Not used") - - /** - * Returns an observable with a list of video for the episode of an anime. - * - * @param episode the episode to get the link for. - */ - @Deprecated( - "Use the 1.x API instead", - ReplaceWith("getVideoList"), - ) - fun fetchVideoList(episode: SEpisode): Observable> = Observable.empty() - - /** - * [1.x API] Get the updated details for a anime. - */ - @Suppress("DEPRECATION") - override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo { - val sAnime = anime.toSAnime() - val networkAnime = fetchAnimeDetails(sAnime).awaitSingle() - sAnime.copyFrom(networkAnime) - return sAnime.toAnimeInfo() - } - - /** - * [1.x API] Get all the available episodes for a anime. - */ - @Suppress("DEPRECATION") - override suspend fun getEpisodeList(anime: AnimeInfo): List { - return fetchEpisodeList(anime.toSAnime()).awaitSingle() - .map { it.toEpisodeInfo() } - } - - /** - * [1.x API] Get a link for the episode of an anime. - */ - @Suppress("DEPRECATION") - override suspend fun getVideoList(episode: EpisodeInfo): List { - return fetchVideoList(episode.toSEpisode()).awaitSingle() - .map { it.toVideoUrl() } - } -} - -fun AnimeSource.icon(): Drawable? = Injekt.get().getAppIconForSource(this) - -fun AnimeSource.getPreferenceKey(): String = "source_$id" - -fun AnimeSource.toAnimeSourceData(): AnimeSourceData = AnimeSourceData(id = id, lang = lang, name = name) - -fun AnimeSource.getNameForAnimeInfo(): String { - val preferences = Injekt.get() - val enabledLanguages = preferences.enabledLanguages().get() - .filterNot { it in listOf("all", "other") } - val hasOneActiveLanguages = enabledLanguages.size == 1 - val isInEnabledLanguages = lang in enabledLanguages - return when { - // For edge cases where user disables a source they got manga of in their library. - hasOneActiveLanguages && !isInEnabledLanguages -> toString() - // Hide the language tag when only one language is used. - hasOneActiveLanguages && isInEnabledLanguages -> name - else -> toString() - } -} - -fun AnimeSource.isLocalOrStub(): Boolean = id == LocalAnimeSource.ID || this is AnimeSourceManager.StubSource diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceExtensions.kt new file mode 100644 index 000000000..e30a331d4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceExtensions.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.animesource + +import android.graphics.drawable.Drawable +import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +fun AnimeSource.icon(): Drawable? = Injekt.get().getAppIconForSource(this.id) + +fun AnimeSource.getPreferenceKey(): String = "source_$id" + +fun AnimeSource.toSourceData(): AnimeSourceData = AnimeSourceData(id = id, lang = lang, name = name) + +fun AnimeSource.getNameForAnimeInfo(): String { + val preferences = Injekt.get() + val enabledLanguages = preferences.enabledLanguages().get() + .filterNot { it in listOf("all", "other") } + val hasOneActiveLanguages = enabledLanguages.size == 1 + val isInEnabledLanguages = lang in enabledLanguages + return when { + // For edge cases where user disables a source they got manga of in their library. + hasOneActiveLanguages && !isInEnabledLanguages -> toString() + // Hide the language tag when only one language is used. + hasOneActiveLanguages && isInEnabledLanguages -> name + else -> toString() + } +} + +fun AnimeSource.isLocal(): Boolean = id == LocalAnimeSource.ID + +fun AnimeSource.isLocalOrStub(): Boolean = isLocal() || this is AnimeSourceManager.StubAnimeSource diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt index 24d43aa8b..c3aabe60e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt @@ -1,42 +1,73 @@ package eu.kanade.tachiyomi.animesource import android.content.Context -import eu.kanade.domain.animesource.interactor.GetAnimeSourceData -import eu.kanade.domain.animesource.interactor.UpsertAnimeSourceData import eu.kanade.domain.animesource.model.AnimeSourceData +import eu.kanade.domain.animesource.repository.AnimeSourceDataRepository import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import rx.Observable -import tachiyomi.animesource.model.AnimeInfo -import tachiyomi.animesource.model.EpisodeInfo import uy.kohesive.injekt.injectLazy +import java.util.concurrent.ConcurrentHashMap -class AnimeSourceManager(private val context: Context) { +class AnimeSourceManager( + private val context: Context, + private val extensionManager: AnimeExtensionManager, + private val sourceRepository: AnimeSourceDataRepository, +) { + private val downloadManager: AnimeDownloadManager by injectLazy() - private val extensionManager: AnimeExtensionManager by injectLazy() - private val getSourceData: GetAnimeSourceData by injectLazy() - private val upsertSourceData: UpsertAnimeSourceData by injectLazy() + private val scope = CoroutineScope(Job() + Dispatchers.IO) - private val sourcesMap = mutableMapOf() - private val stubSourcesMap = mutableMapOf() + private var sourcesMap = ConcurrentHashMap() + set(value) { + field = value + sourcesMapFlow.value = field + } - private val _catalogueSources: MutableStateFlow> = MutableStateFlow(listOf()) - val catalogueSources: Flow> = _catalogueSources - val onlineSources: Flow> = - _catalogueSources.map { sources -> sources.filterIsInstance() } + private val sourcesMapFlow = MutableStateFlow(sourcesMap) + + private val stubSourcesMap = ConcurrentHashMap() + + val catalogueSources: Flow> = sourcesMapFlow.map { it.values.filterIsInstance() } + val onlineSources: Flow> = catalogueSources.map { sources -> sources.filterIsInstance() } init { - createInternalSources().forEach { registerSource(it) } + scope.launch { + extensionManager.installedExtensionsFlow + .collectLatest { extensions -> + val mutableMap = ConcurrentHashMap(mapOf(LocalAnimeSource.ID to LocalAnimeSource(context))) + extensions.forEach { extension -> + extension.sources.forEach { + mutableMap[it.id] = it + registerStubSource(it.toSourceData()) + } + } + sourcesMap = mutableMap + } + } + + scope.launch { + sourceRepository.subscribeAll() + .collectLatest { sources -> + val mutableMap = stubSourcesMap.toMutableMap() + sources.forEach { + mutableMap[it.id] = StubAnimeSource(it) + } + } + } } fun get(sourceKey: Long): AnimeSource? { @@ -53,99 +84,63 @@ class AnimeSourceManager(private val context: Context) { fun getCatalogueSources() = sourcesMap.values.filterIsInstance() - fun getStubSources(): List { + fun getStubSources(): List { val onlineSourceIds = getOnlineSources().map { it.id } return stubSourcesMap.values.filterNot { it.id in onlineSourceIds } } - internal fun registerSource(source: AnimeSource) { - if (!sourcesMap.containsKey(source.id)) { - sourcesMap[source.id] = source - } - registerStubSource(source.toAnimeSourceData()) - triggerCatalogueSources() - } - private fun registerStubSource(sourceData: AnimeSourceData) { - launchIO { - val dbSourceData = getSourceData.await(sourceData.id) - - if (dbSourceData != sourceData) { - upsertSourceData.await(sourceData) - } - if (stubSourcesMap[sourceData.id]?.toAnimeSourceData() != sourceData) { - stubSourcesMap[sourceData.id] = StubSource(sourceData) + scope.launch { + val (id, lang, name) = sourceData + val dbSourceData = sourceRepository.getSourceData(id) + if (dbSourceData == sourceData) return@launch + sourceRepository.upsertSourceData(id, lang, name) + if (dbSourceData != null) { + downloadManager.renameSource( + StubAnimeSource(dbSourceData), + StubAnimeSource(sourceData), + ) } } } - internal fun unregisterSource(source: AnimeSource) { - sourcesMap.remove(source.id) - triggerCatalogueSources() - } - - private fun triggerCatalogueSources() { - _catalogueSources.update { - sourcesMap.values.filterIsInstance() + private suspend fun createStubSource(id: Long): StubAnimeSource { + sourceRepository.getSourceData(id)?.let { + return StubAnimeSource(it) } - } - - private fun createInternalSources(): List = listOf( - LocalAnimeSource(context), - ) - - private suspend fun createStubSource(id: Long): StubSource { - getSourceData.await(id)?.let { - return StubSource(it) - } - return StubSource(AnimeSourceData(id, "", "")) + return StubAnimeSource(AnimeSourceData(id, "", "")) } @Suppress("OverridingDeprecatedMember") - open inner class StubSource(val sourceData: AnimeSourceData) : AnimeSource { - - override val name: String = sourceData.name - - override val lang: String = sourceData.lang + open inner class StubAnimeSource(private val sourceData: AnimeSourceData) : AnimeSource { override val id: Long = sourceData.id - override fun fetchAnimeDetails(anime: SAnime): Observable { - return Observable.error(getSourceNotInstalledException()) - } + override val name: String = sourceData.name.ifBlank { id.toString() } - override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo { + override val lang: String = sourceData.lang + + override suspend fun getAnimeDetails(anime: SAnime): SAnime { throw getSourceNotInstalledException() } - override fun fetchEpisodeList(anime: SAnime): Observable> { - return Observable.error(getSourceNotInstalledException()) - } - - override suspend fun getEpisodeList(anime: AnimeInfo): List { + override suspend fun getEpisodeList(anime: SAnime): List { throw getSourceNotInstalledException() } - override fun fetchVideoList(episode: SEpisode): Observable> { - return Observable.error(getSourceNotInstalledException()) - } - - override suspend fun getVideoList(episode: EpisodeInfo): List { + override suspend fun getVideoList(episode: SEpisode): List