Compare commits

...

35 Commits
v6.5.2 ... v6.6

Author SHA1 Message Date
Koitharu
3acca44b5e Update parsers 2024-01-06 15:33:49 +02:00
Koitharu
c7da4feb8f Fix details bottom sheet menu 2024-01-06 15:18:49 +02:00
Koitharu
baee9bee0e Update shikimori domain 2024-01-06 14:27:07 +02:00
Koitharu
ec41d36508 Fix converting bitmaps in local manga 2024-01-06 14:13:56 +02:00
Koitharu
8b63d227a7 Translated using Weblate (Russian)
Currently translated at 99.2% (550 of 554 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
dont wana say
c9b48c8207 Translated using Weblate (Estonian)
Currently translated at 79.7% (442 of 554 strings)

Translated using Weblate (Estonian)

Currently translated at 85.7% (6 of 7 strings)

Co-authored-by: dont wana say <273ex2vl6@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/et/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-05 16:48:58 +02:00
Haithem Dhiaeddine
6d7ce5205e Translated using Weblate (Arabic)
Currently translated at 51.4% (285 of 554 strings)

Co-authored-by: Haithem Dhiaeddine <haithemdjabi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Ridhoardhiansyah7
5a02d534c9 Translated using Weblate (Indonesian)
Currently translated at 97.6% (540 of 553 strings)

Co-authored-by: Ridhoardhiansyah7 <Zxx97607@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
gallegonovato
6128e5b699 Translated using Weblate (Spanish)
Currently translated at 100.0% (554 of 554 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (553 of 553 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
InfinityDouki56
717a0ad4fb Translated using Weblate (Filipino)
Currently translated at 99.8% (553 of 554 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Anon
dee94ac0c4 Translated using Weblate (Serbian)
Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Koitharu
9eec9a9957 Fix favorites backup #621 2024-01-05 16:40:48 +02:00
Koitharu
a4966b4661 Update parsers 2024-01-05 16:29:22 +02:00
Koitharu
58e570601d PageLoader improvements 2024-01-05 10:46:32 +02:00
Koitharu
7247cba855 Improve pages preview on details screen 2024-01-04 16:59:39 +02:00
Koitharu
d6012f9ddd Reset filter menu action 2024-01-04 11:05:09 +02:00
Koitharu
2eedd0b4a8 Fix filter chips 2024-01-04 10:37:06 +02:00
Koitharu
5e6da9bb1c Pages thumbnails on details screen 2024-01-03 19:47:35 +02:00
Koitharu
2f2a5b868d Excluded tags and content rating in filter 2024-01-02 20:18:44 +02:00
Isira Seneviratne
3f2e32dcc2 Revert to Java 8 2023-12-31 11:15:42 +02:00
Isira Seneviratne
004109a6bc Switch to java.time 2023-12-31 11:15:42 +02:00
Isira Seneviratne
6159ee36c4 Use TypedValueCompat 2023-12-31 11:15:29 +02:00
Koitharu
3b7d83dd6f Bump version 2023-12-31 10:59:30 +02:00
Koitharu
877a018ced Fix crashes 2023-12-31 10:28:46 +02:00
Koitharu
2e80b330e9 Fix tracker duplicates 2023-12-31 10:17:43 +02:00
Koitharu
42ca38e693 Update dependencies 2023-12-31 10:06:48 +02:00
Feroli
d2fc3354af Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Feroli <feroli@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translation: Kotatsu/plurals
2023-12-31 10:03:25 +02:00
Koitharu
2a870e6167 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (548 of 548 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
gallegonovato
393a9c2791 Translated using Weblate (Spanish)
Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
dont wana say
4c69839076 Translated using Weblate (Estonian)
Currently translated at 75.5% (414 of 548 strings)

Added translation using Weblate (Estonian)

Added translation using Weblate (Estonian)

Co-authored-by: dont wana say <273ex2vl6@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
Koitharu
e37455e790 Update parsers and add support for Upcoming state 2023-12-28 17:48:00 +02:00
Paper Jack
36259ba901 Translated using Weblate (Italian)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-12-28 16:50:47 +02:00
Nicolò Bertazzo
5b041b9a49 Translated using Weblate (Italian)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Nicolò Bertazzo <n.bertazzo@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-12-28 16:50:47 +02:00
Koitharu
1734e888d6 Move new sources tip to catalog 2023-12-26 20:24:14 +02:00
Koitharu
9108646cea Update parsers 2023-12-25 19:52:37 +02:00
109 changed files with 2116 additions and 483 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
/.idea/compiler.xml /.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml /.idea/kotlinc.xml

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 608 versionCode = 611
versionName = '6.5.2' versionName = '6.6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,12 +82,12 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:4a0e7221b0') { implementation('com.github.KotatsuApp:kotatsu-parsers:e03d0efe71') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.21' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga, manga = manga,
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = Date(createdAt), createdAt = Instant.ofEpochMilli(createdAt),
percent = percent, percent = percent,
) )
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = createdAt.time, createdAt = createdAt.toEpochMilli(),
percent = percent, percent = percent,
) )

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.hasImageExtension import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date import java.time.Instant
data class Bookmark( data class Bookmark(
val manga: Manga, val manga: Manga,
@@ -13,7 +13,7 @@ data class Bookmark(
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val imageUrl: String, val imageUrl: String,
val createdAt: Date, val createdAt: Instant,
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
@@ -58,6 +59,10 @@ class CaptchaNotifier(
manager.notify(TAG, exception.source.hashCode(), notification) manager.notify(TAG, exception.source.hashCode(), notification)
} }
fun dismiss(source: MangaSource) {
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
}
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable

View File

@@ -55,7 +55,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }

View File

@@ -5,10 +5,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.util.Date import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
val filename = buildString { val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_') append('_')
append(Date().format("ddMMyyyy")) append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
append(".bk.zip") append(".bk.zip")
} }
BackupZipOutput(File(dir, filename)) BackupZipOutput(File(dir, filename))

View File

@@ -31,6 +31,16 @@ abstract class TagsDao {
) )
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity> abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import okio.IOException import okio.IOException
import java.util.Date import java.time.Instant
import java.time.temporal.ChronoUnit
class TooManyRequestExceptions( class TooManyRequestExceptions(
val url: String, val url: String,
val retryAt: Date?, val retryAt: Instant?,
) : IOException() { ) : IOException() {
val retryAfter: Long val retryAfter: Long
get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0) get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
} }

View File

@@ -20,8 +20,9 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.time.LocalDateTime
import java.util.Date import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@@ -41,11 +42,7 @@ class FileLogger(
} }
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isLoggingEnabled get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance( private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>() private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex() private val mutex = Mutex()
private var flushJob: Job? = null private var flushJob: Job? = null
@@ -55,7 +52,7 @@ class FileLogger(
return return
} }
val text = buildString { val text = buildString {
append(dateFormat.format(Date())) append(dateTimeFormatter.format(LocalDateTime.now()))
append(": ") append(": ")
if (e != null) { if (e != null) {
append("E!") append("E!")

View File

@@ -5,7 +5,7 @@ import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Date import java.time.Instant
@Parcelize @Parcelize
data class FavouriteCategory( data class FavouriteCategory(
@@ -13,7 +13,7 @@ data class FavouriteCategory(
val title: String, val title: String,
val sortKey: Int, val sortKey: Int,
val order: ListSortOrder, val order: ListSortOrder,
val createdAt: Date, val createdAt: Instant,
val isTrackingEnabled: Boolean, val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean, val isVisibleInLibrary: Boolean,
) : Parcelable, ListModel { ) : Parcelable, ListModel {

View File

@@ -7,11 +7,13 @@ import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@JvmName("mangaIds") @JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
@@ -42,15 +44,25 @@ val MangaState.titleResId: Int
MangaState.FINISHED -> R.string.state_finished MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused MangaState.PAUSED -> R.string.state_paused
MangaState.UPCOMING -> R.string.state_upcoming
} }
@get:DrawableRes @get:DrawableRes
val MangaState.iconResId: Int val MangaState.iconResId: Int
get() = when (this) { get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_state_ongoing MangaState.ONGOING -> R.drawable.ic_play
MangaState.FINISHED -> R.drawable.ic_state_finished MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause MangaState.PAUSED -> R.drawable.ic_action_pause
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
}
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
} }
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {

View File

@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.time.Instant
@Parcelize @Parcelize
data class MangaHistory( data class MangaHistory(
val createdAt: Date, val createdAt: Instant,
val updatedAt: Date, val updatedAt: Instant,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val percent: Float, val percent: Float,
) : Parcelable ) : Parcelable

View File

@@ -4,15 +4,11 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.text.SimpleDateFormat import java.time.Instant
import java.util.Date import java.time.ZonedDateTime
import java.util.Locale import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor { class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
if (response.code == 429) { if (response.code == 429) {
@@ -27,10 +23,8 @@ class RateLimitInterceptor : Interceptor {
return response return response
} }
private fun String.parseRetryDate(): Date? { private fun String.parseRetryDate(): Instant? {
toIntOrNull()?.let { return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong())) ?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
}
return dateFormat.parse(this)
} }
} }

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -28,10 +29,16 @@ interface MangaRepository {
val states: Set<MangaState> val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -49,6 +50,9 @@ class RemoteMangaRepository(
override val states: Set<MangaState> override val states: Set<MangaState>
get() = parser.availableStates get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) { set(value) {
@@ -58,6 +62,12 @@ class RemoteMangaRepository(
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {

View File

@@ -204,6 +204,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
val defaultDetailsTab: Int
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
val isContentPrefetchEnabled: Boolean val isContentPrefetchEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
@@ -295,13 +298,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
var readerColorFilter: ReaderColorFilter? var readerColorFilter: ReaderColorFilter?
get() { get() = runCatching {
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness) val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast) val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted) val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale) val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
return ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty } ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
} }.getOrNull()
set(value) { set(value) {
prefs.edit { prefs.edit {
val cf = value ?: ReaderColorFilter.EMPTY val cf = value ?: ReaderColorFilter.EMPTY
@@ -559,6 +562,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_DETAILS_TAB = "details_tab"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -8,7 +10,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -62,4 +66,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
protected fun setTitle(title: CharSequence?) { protected fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }
protected fun startActivitySafe(intent: Intent) {
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
} }

View File

@@ -38,4 +38,12 @@ abstract class BoundsScrollListener(
firstVisibleItemPosition: Int, firstVisibleItemPosition: Int,
visibleItemCount: Int visibleItemCount: Int
) = Unit ) = Unit
fun invalidate(recyclerView: RecyclerView) {
onScrolled(recyclerView, 0, 0)
}
fun postInvalidate(recyclerView: RecyclerView) = recyclerView.post {
invalidate(recyclerView)
}
} }

View File

@@ -2,9 +2,8 @@ package org.koitharu.kotatsu.core.ui.model
import android.content.res.Resources import android.content.res.Resources
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.daysDiff import java.time.LocalDate
import org.koitharu.kotatsu.core.util.ext.format import java.time.format.DateTimeFormatter
import java.util.Date
sealed class DateTimeAgo { sealed class DateTimeAgo {
@@ -74,32 +73,22 @@ sealed class DateTimeAgo {
} }
} }
class Absolute(private val date: Date) : DateTimeAgo() { data class Absolute(private val date: LocalDate) : DateTimeAgo() {
private val day = date.daysDiff(0)
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return if (date.time == 0L) { return if (date == EPOCH_DATE) {
resources.getString(R.string.unknown) resources.getString(R.string.unknown)
} else { } else {
date.format("d MMMM") date.format(formatter)
} }
} }
override fun equals(other: Any?): Boolean { override fun toString() = "abs_${date.toEpochDay()}"
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Absolute companion object {
// TODO: Use Java 9's LocalDate.EPOCH.
return day == other.day private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
private val formatter = DateTimeFormatter.ofPattern("d MMMM")
} }
override fun hashCode(): Int {
return day
}
override fun toString() = "abs_$day"
} }
object LongAgo : DateTimeAgo() { object LongAgo : DateTimeAgo() {

View File

@@ -12,4 +12,5 @@ val SortOrder.titleRes: Int
SortOrder.RATING -> R.string.by_rating SortOrder.RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest SortOrder.NEWEST -> R.string.newest
SortOrder.ALPHABETICAL -> R.string.by_name SortOrder.ALPHABETICAL -> R.string.by_name
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse
} }

View File

@@ -108,7 +108,6 @@ class ChipsView @JvmOverloads constructor(
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check)
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)

View File

@@ -9,10 +9,8 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
import org.acra.ACRA import org.acra.ACRA
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import java.text.DateFormat import java.time.LocalTime
import java.text.SimpleDateFormat import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.Locale
import java.util.WeakHashMap import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,7 +18,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks { class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
private val keys = WeakHashMap<Any, String>() private val keys = WeakHashMap<Any, String>()
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
@@ -47,11 +44,10 @@ class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), Def
} }
private fun Any.key() = keys.getOrPut(this) { private fun Any.key() = keys.getOrPut(this) {
"${time()}: ${javaClass.simpleName}" val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS)
"$time: ${javaClass.simpleName}"
} }
private fun time() = timeFormat.format(Date())
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k -> private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
val v = get(k) val v = get(k)

View File

@@ -17,6 +17,7 @@ import android.content.SyncResult
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.database.SQLException import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -29,6 +30,7 @@ import android.view.Window
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -37,6 +39,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -45,7 +48,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException import okio.IOException
import okio.use
import org.json.JSONException import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -53,6 +58,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong import kotlin.math.roundToLong
val Context.activityManager: ActivityManager? val Context.activityManager: ActivityManager?
@@ -230,3 +236,18 @@ fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >
} else { } else {
NotificationManagerCompat.from(this).areNotificationsEnabled() NotificationManagerCompat.from(this).areNotificationsEnabled()
} }
@WorkerThread
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
output.outputStream().use { os ->
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {
throw IOException("Failed to encode bitmap into PNG format")
}
}
}
fun Context.ensureRamAtLeast(requiredSize: Long) {
if (ramAvailable < requiredSize) {
throw IllegalStateException("Not enough free memory")
}
}

View File

@@ -1,30 +1,32 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import android.text.format.DateUtils import java.time.Instant
import java.text.SimpleDateFormat import java.time.LocalDate
import java.util.* import java.time.LocalDateTime
import java.util.concurrent.TimeUnit import java.time.ZoneId
import java.time.temporal.ChronoUnit
@SuppressLint("SimpleDateFormat") fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this) // TODO: Use Java 9's LocalDate.ofInstant().
val localDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate()
val now = LocalDate.now()
val diffDays = localDate.until(now, ChronoUnit.DAYS)
fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelativeTimeSpanString( return when {
time, System.currentTimeMillis(), minResolution, diffDays == 0L -> {
) if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
else DateTimeAgo.Today
fun Date.daysDiff(other: Long): Int { }
val thisDay = time / TimeUnit.DAYS.toMillis(1L) diffDays == 1L -> DateTimeAgo.Yesterday
val otherDay = other / TimeUnit.DAYS.toMillis(1L) diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
return (thisDay - otherDay).toInt() else -> {
} val diffMonths = localDate.until(now, ChronoUnit.MONTHS)
if (showMonths && diffMonths <= 6) {
fun Date.startOfDay(): Long { DateTimeAgo.MonthsAgo(diffMonths.toInt())
val calendar = Calendar.getInstance() } else {
calendar.time = this DateTimeAgo.Absolute(localDate)
calendar[Calendar.HOUR_OF_DAY] = 0 }
calendar[Calendar.MINUTE] = 0 }
calendar[Calendar.SECOND] = 0 }
calendar[Calendar.MILLISECOND] = 0
return calendar.timeInMillis
} }

View File

@@ -3,18 +3,18 @@ package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.util.TypedValue
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.util.TypedValueCompat
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Px @Px
fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt() fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt()
@Px @Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density fun Resources.resolveDp(dp: Float) = TypedValueCompat.dpToPx(dp, displayMetrics)
@Px @Px
fun Resources.resolveSp(sp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, displayMetrics) fun Resources.resolveSp(sp: Float) = TypedValueCompat.spToPx(sp, displayMetrics)
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean { fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {

View File

@@ -8,6 +8,8 @@ import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.descendants import androidx.core.view.descendants
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -153,3 +155,9 @@ fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
setOnContextClickListener(listener::onLongClick) setOnContextClickListener(listener::onLongClick)
} }
} }
val Toolbar.menuView: ActionMenuView?
get() {
menu // to call ensureMenu()
return children.firstNotNullOfOrNull { it as? ActionMenuView }
}

View File

@@ -6,12 +6,14 @@ import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class ChaptersBottomSheetMediator( class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>, private val behavior: BottomSheetBehavior<*>,
private val pager: ViewPager2,
) : OnBackPressedCallback(false), ) : OnBackPressedCallback(false),
ActionModeListener, ActionModeListener,
OnLayoutChangeListener, View.OnGenericMotionListener { OnLayoutChangeListener, View.OnGenericMotionListener {
@@ -74,6 +76,7 @@ class ChaptersBottomSheetMediator(
fun lock() { fun lock() {
lockCounter++ lockCounter++
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
} }
fun unlock() { fun unlock() {
@@ -82,5 +85,6 @@ class ChaptersBottomSheetMediator(
lockCounter = 0 lockCounter = 0
} }
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
} }
} }

View File

@@ -22,13 +22,14 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.MenuHost
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -37,15 +38,19 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.menuView
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
@@ -54,6 +59,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.DetailsPagerAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -74,10 +80,18 @@ class DetailsActivity :
@Inject @Inject
lateinit var appShortcutManager: AppShortcutManager lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var settings: AppSettings
private var buttonTip: WeakReference<ButtonTip>? = null private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
val secondaryMenuHost: MenuHost
get() = viewBinding.toolbarChapters ?: this
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
private set
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -93,24 +107,22 @@ class DetailsActivity :
if (viewBinding.layoutBottom != null) { if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior) val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager)
actionModeDelegate.addListener(bsMediator) actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator) checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator) onBackPressedDispatcher.addCallback(bsMediator)
chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator) bottomSheetMediator = bsMediator
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged) behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener { viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator) viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
} }
onBackPressedDispatcher.addCallback(chaptersMenuProvider) initPager()
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent( viewModel.onError.observeEvent(
this, this,
SnackbarErrorObserver( SnackbarErrorObserver(
@@ -124,19 +136,16 @@ class DetailsActivity :
}, },
), ),
) )
viewModel.onShowToast.observeEvent(this) { viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.onShowTip.observeEvent(this) { showTip() } viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
viewModel.isChaptersReversed.observe( val chaptersMenuInvalidator = MenuInvalidator(viewBinding.toolbarChapters ?: this)
this, viewModel.isChaptersReversed.observe(this, chaptersMenuInvalidator)
MenuInvalidator(viewBinding.toolbarChapters ?: this), viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
)
val menuInvalidator = MenuInvalidator(this) val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator) viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator)
@@ -153,7 +162,7 @@ class DetailsActivity :
DetailsMenuProvider( DetailsMenuProvider(
activity = this, activity = this,
viewModel = viewModel, viewModel = viewModel,
snackbarHost = viewBinding.containerChapters, snackbarHost = viewBinding.pager,
appShortcutManager = appShortcutManager, appShortcutManager = appShortcutManager,
), ),
) )
@@ -217,12 +226,11 @@ class DetailsActivity :
TransitionManager.beginDelayedTransition(toolbar, transition) TransitionManager.beginDelayedTransition(toolbar, transition)
} }
if (isExpanded) { if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material) toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else { } else {
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null toolbar.navigationIcon = null
} }
toolbar.menuView?.isVisible = isExpanded
viewBinding.buttonRead.isGone = isExpanded viewBinding.buttonRead.isGone = isExpanded
} }
@@ -293,6 +301,18 @@ class DetailsActivity :
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
} }
private fun onNewChaptersChanged(count: Int) {
val tab = viewBinding.tabs.getTabAt(0) ?: return
if (count == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.horizontalOffsetWithText = -resources.getDimensionPixelOffset(R.dimen.margin_small)
badge.number = count
badge.isVisible = true
}
}
private fun showBranchPopupMenu(v: View) { private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v) val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value val branches = viewModel.branches.value
@@ -326,9 +346,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = Snackbar.make(viewBinding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) .show()
snackbar.show()
} else { } else {
startActivity( startActivity(
IntentBuilder(this) IntentBuilder(this)
@@ -343,6 +362,14 @@ class DetailsActivity :
} }
} }
private fun initPager() {
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
val adapter = DetailsPagerAdapter(this)
viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
}
private fun showBottomSheet(isVisible: Boolean) { private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return if (view.isVisible == isVisible) return
@@ -353,17 +380,6 @@ class DetailsActivity :
view.isVisible = isVisible view.isVisible = isVisible
} }
private fun makeSnackbar(
text: CharSequence,
@BaseTransientBottomBar.Duration duration: Int,
): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.toolbarChapters
}
return sb
}
private class PrefetchObserver( private class PrefetchObserver(
private val context: Context, private val context: Context,
) : FlowCollector<List<ChapterListItem>?> { ) : FlowCollector<List<ChapterListItem>?> {

View File

@@ -10,8 +10,6 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -23,13 +21,14 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
import org.koitharu.kotatsu.core.model.countChaptersByBranch import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -40,7 +39,6 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -66,7 +64,6 @@ import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
@@ -74,7 +71,6 @@ import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorShee
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class DetailsFragment : class DetailsFragment :
@@ -121,7 +117,7 @@ class DetailsFragment :
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
combine(viewModel.chapters, viewModel.newChaptersCount, ::Pair).observe(viewLifecycleOwner, ::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -181,28 +177,13 @@ class DetailsFragment :
ratingBar.isVisible = false ratingBar.isVisible = false
} }
when (manga.state) { infoLayout.textViewState.apply {
MangaState.FINISHED -> infoLayout.textViewState.apply { manga.state?.let { state ->
textAndVisible = resources.getString(R.string.state_finished) textAndVisible = resources.getString(state.titleResId)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished) drawableTop = ContextCompat.getDrawable(context, state.iconResId)
} ?: run {
isVisible = false
} }
MangaState.ONGOING -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
MangaState.ABANDONED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_abandoned)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_abandoned)
}
MangaState.PAUSED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_paused)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_action_pause)
}
null -> infoLayout.textViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
@@ -218,8 +199,7 @@ class DetailsFragment :
} }
} }
private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) { private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val (chapters, newChapters) = data
val infoLayout = requireViewBinding().infoLayout val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
@@ -227,19 +207,7 @@ class DetailsFragment :
val count = chapters.countChaptersByBranch() val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count) val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) { infoLayout.textViewChapters.text = chaptersText
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
} }
} }

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
@@ -30,6 +31,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
@@ -80,7 +82,7 @@ class DetailsViewModel @Inject constructor(
private val mangaId = intent.mangaId private val mangaId = intent.mangaId
private var loadingJob: Job private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>() val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowTip = MutableEventFlow<Unit>() val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>() val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
@@ -234,7 +236,7 @@ class DetailsViewModel @Inject constructor(
fun deleteLocal() { fun deleteLocal() {
val m = details.value?.local?.manga val m = details.value?.local?.manga
if (m == null) { if (m == null) {
onShowToast.call(R.string.file_not_found) errorEvent.call(FileNotFoundException())
return return
} }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
@@ -246,7 +248,7 @@ class DetailsViewModel @Inject constructor(
fun removeBookmark(bookmark: Bookmark) { fun removeBookmark(bookmark: Bookmark) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
bookmarksRepository.removeBookmark(bookmark) bookmarksRepository.removeBookmark(bookmark)
onShowToast.call(R.string.bookmark_removed) onActionDone.call(ReversibleAction(R.string.bookmark_removed, null))
} }
} }

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.details.ui.pager
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity),
TabLayoutMediator.TabConfigurationStrategy {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ChaptersFragment()
1 -> PagesFragment()
else -> throw IllegalArgumentException("Invalid position $position")
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(
when (position) {
0 -> R.string.chapters
1 -> R.string.pages
else -> 0
},
)
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui.pager.chapters
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@@ -11,6 +10,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
@@ -20,6 +20,9 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.ChaptersMenuProvider
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -57,9 +60,6 @@ class ChaptersFragment :
checkNotNull(selectionController).attachToRecyclerView(this) checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true) setHasFixedSize(true)
adapter = chaptersAdapter adapter = chaptersAdapter
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
scrollIndicators = if (resources.getBoolean(R.bool.is_tablet)) 0 else View.SCROLL_INDICATOR_TOP
}
} }
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
@@ -69,6 +69,12 @@ class ChaptersFragment :
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) { viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it) selectionController?.onItemLongClick(it)
} }
val detailsActivity = activity as? DetailsActivity
if (detailsActivity != null) {
val menuProvider = ChaptersMenuProvider(viewModel, detailsActivity.bottomSheetMediator)
activity?.onBackPressedDispatcher?.addCallback(menuProvider)
detailsActivity.secondaryMenuHost.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -0,0 +1,189 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class PagesFragment :
BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> {
private val detailsViewModel by activityViewModels<DetailsViewModel>()
private val viewModel by viewModels<PagesViewModel>()
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private var scrollListener: ScrollListener? = null
private val spanSizeLookup = SpanSizeLookup()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
combine(
detailsViewModel.details,
detailsViewModel.history,
detailsViewModel.selectedBranch,
) { details, history, branch ->
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
PagesViewModel.State(details, history, branch)
} else {
null
}
}.observe(this, viewModel::updateState)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding {
return FragmentPagesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = MangaListSpanResolver(binding.root.resources)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@PagesFragment,
)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = thumbnailsAdapter
setHasFixedSize(true)
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
addOnScrollListener(ScrollListener().also { scrollListener = it })
(layoutManager as GridLayoutManager).let {
it.spanSizeLookup = spanSizeLookup
it.spanCount = checkNotNull(spanResolver).spanCount
}
}
detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
}
override fun onDestroyView() {
spanResolver = null
scrollListener = null
thumbnailsAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {
val manga = detailsViewModel.manga.value ?: return
val state = ReaderState(item.page.chapterId, item.page.index, 0)
val intent = IntentBuilder(view.context).manga(manga).state(state).build()
startActivity(intent)
}
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = thumbnailsAdapter ?: return
if (adapter.itemCount == 0) {
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (position > 0) {
val spanCount = spanResolver?.spanCount ?: 0
val offset = if (position > spanCount + 1) {
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
} else {
position = 0
0
}
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
adapter.emit(list)
scrollCallback.run()
} else {
adapter.emit(list)
}
} else {
adapter.emit(list)
}
spanSizeLookup.invalidateCache()
viewBinding?.recyclerView?.let {
scrollListener?.postInvalidate(it)
}
}
private fun onNoChaptersChanged(isNoChapters: Boolean) {
with(viewBinding ?: return) {
textViewHolder.isVisible = isNoChapters
recyclerView.isInvisible = isNoChapters
}
}
private inner class ScrollListener : BoundsScrollListener(3, 3) {
override fun onScrolledToStart(recyclerView: RecyclerView) {
viewModel.loadPrevChapter()
}
override fun onScrolledToEnd(recyclerView: RecyclerView) {
viewModel.loadNextChapter()
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (thumbnailsAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}

View File

@@ -0,0 +1,115 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
@HiltViewModel
class PagesViewModel @Inject constructor(
private val chaptersLoader: ChaptersLoader,
) : BaseViewModel() {
private var loadingJob: Job? = null
private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null
private val state = MutableStateFlow<State?>(null)
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val isLoadingUp = MutableStateFlow(false)
val isLoadingDown = MutableStateFlow(false)
init {
loadingJob = launchLoadingJob(Dispatchers.Default) {
val firstState = state.firstNotNull()
doInit(firstState)
launchJob(Dispatchers.Default) {
state.collectLatest {
if (it != null) {
doInit(it)
}
}
}
}
}
fun updateState(newState: State?) {
if (newState != null) {
state.value = newState
}
}
fun loadPrevChapter() {
if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) {
return
}
loadingPrevJob = loadPrevNextChapter(isNext = false)
}
fun loadNextChapter() {
if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) {
return
}
loadingNextJob = loadPrevNextChapter(isNext = true)
}
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) {
chaptersLoader.loadSingleChapter(initialChapterId)
}
updateList(state.history)
}
private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) {
val indicator = if (isNext) isLoadingDown else isLoadingUp
indicator.value = true
try {
val currentState = state.firstNotNull()
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext)
updateList(currentState.history)
} finally {
indicator.value = false
}
}
private fun updateList(history: MangaHistory?) {
val snapshot = chaptersLoader.snapshot()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
var previousChapterId = 0L
for (page in snapshot) {
if (page.chapterId != previousChapterId) {
chaptersLoader.peekChapter(page.chapterId)?.let {
add(ListHeader(it.name))
}
previousChapterId = page.chapterId
}
this += PageThumbnail(
isCurrent = history?.let {
page.chapterId == it.chapterId && page.index == it.page
} ?: false,
page = page,
)
}
}
thumbnails.value = pages
}
data class State(
val details: MangaDetails,
val history: MangaHistory?,
val branch: String?
)
}

View File

@@ -4,7 +4,7 @@ import androidx.work.Data
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
data class DownloadState( data class DownloadState(
val manga: Manga, val manga: Manga,
@@ -72,7 +72,7 @@ data class DownloadState(
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
} }

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
import java.util.UUID import java.util.UUID
data class DownloadItemModel( data class DownloadItemModel(
@@ -21,7 +21,7 @@ data class DownloadItemModel(
val max: Int, val max: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val timestamp: Date, val timestamp: Instant,
val chaptersDownloaded: Int, val chaptersDownloaded: Int,
val isExpanded: Boolean, val isExpanded: Boolean,
val chapters: StateFlow<List<DownloadChapter>?>, val chapters: StateFlow<List<DownloadChapter>?>,

View File

@@ -28,8 +28,8 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.isEmpty import org.koitharu.kotatsu.core.util.ext.isEmpty
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
@@ -44,10 +44,8 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList import java.util.LinkedList
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -225,7 +223,7 @@ class DownloadsViewModel @Inject constructor(
WorkInfo.State.ENQUEUED -> queued += item WorkInfo.State.ENQUEUED -> queued += item
else -> { else -> {
val date = timeAgo(item.timestamp) val date = calculateTimeAgo(item.timestamp)
if (prevDate != date) { if (prevDate != date) {
destination += ListHeader(date) destination += ListHeader(date)
} }
@@ -275,19 +273,6 @@ class DownloadsViewModel @Inject constructor(
) )
} }
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.Absolute(date)
}
}
private fun emptyStateList() = listOf( private fun emptyStateList() = listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,

View File

@@ -76,12 +76,9 @@ class ExploreRepository @Inject constructor(
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced( filter = MangaListFilter.Advanced.Builder(order)
sortOrder = order, .tags(setOfNotNull(tag))
tags = setOfNotNull(tag), .build(),
locale = null,
states = emptySet(),
),
).asArrayList() ).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

View File

@@ -111,7 +111,7 @@ class ExploreFragment :
} }
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(SettingsActivity.newManageSourcesIntent(view.context)) startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
} }
override fun onPrimaryButtonClick(tipView: TipView) { override fun onPrimaryButtonClick(tipView: TipView) {

View File

@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -126,24 +125,18 @@ class ExploreViewModel @Inject constructor(
randomLoading: Boolean, randomLoading: Boolean,
newSources: Set<MangaSource>, newSources: Set<MangaSource>,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(sources.size + 4) val result = ArrayList<ListModel>(sources.size + 3)
result += ExploreButtons(randomLoading) result += ExploreButtons(randomLoading)
if (recommendation != null) { if (recommendation != null) {
result += ListHeader(R.string.suggestions) result += ListHeader(R.string.suggestions)
result += RecommendationsItem(recommendation) result += RecommendationsItem(recommendation)
} }
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.manage) result += ListHeader(
if (newSources.isNotEmpty()) { textRes = R.string.remote_sources,
result += TipModel( buttonTextRes = R.string.catalog,
key = TIP_NEW_SOURCES, badge = if (newSources.isNotEmpty()) "" else null,
title = R.string.new_sources_text, )
text = R.string.new_sources_text,
icon = R.drawable.ic_explore_normal,
primaryButtonText = R.string.manage,
secondaryButtonText = R.string.discard,
)
}
sources.mapTo(result) { MangaSourceItem(it, isGrid) } sources.mapTo(result) { MangaSourceItem(it, isGrid) }
} else { } else {
result += EmptyHint( result += EmptyHint(

View File

@@ -4,14 +4,14 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import java.util.Date import java.time.Instant
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id, id = id,
title = title, title = title,
sortKey = sortKey, sortKey = sortKey,
order = ListSortOrder(order, ListSortOrder.NEWEST), order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Date(createdAt), createdAt = Instant.ofEpochMilli(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,
) )

View File

@@ -46,6 +46,10 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +

View File

@@ -41,6 +41,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -67,11 +68,28 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = MutableStateFlow( private val currentState = MutableStateFlow(
MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()), MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder,
tags = emptySet(),
tagsExclude = emptySet(),
locale = null,
states = emptySet(),
contentRating = emptySet(),
),
) )
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
} }
private val tagsFlow = flow {
val localTags = localTags.get()
emit(PendingData(localTags, isLoading = true, error = null))
tryLoadTags()
.onSuccess { remoteTags ->
emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
}.onFailure {
emit(PendingData(localTags, isLoading = false, error = it))
}
}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), PendingData(emptySet(), true, null))
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync() private var availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null private var allTagsLoadJob: Job? = null
@@ -96,6 +114,22 @@ class FilterCoordinator @Inject constructor(
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (repository.isTagsExclusionSupported) {
combine(
currentState.distinctUntilChangedBy { it.tagsExclude },
getBottomTagsAsFlow(4),
) { state, tags ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tagsExclude,
isLoading = tags.isLoading,
error = tags.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
} else {
MutableStateFlow(emptyProperty())
}
override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine( override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine(
currentState.distinctUntilChangedBy { it.sortOrder }, currentState.distinctUntilChangedBy { it.sortOrder },
flowOf(repository.sortOrders), flowOf(repository.sortOrders),
@@ -120,6 +154,18 @@ class FilterCoordinator @Inject constructor(
) )
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterContentRating: StateFlow<FilterProperty<ContentRating>> = combine(
currentState.distinctUntilChangedBy { it.contentRating },
flowOf(repository.contentRatings),
) { rating, ratings ->
FilterProperty(
availableItems = ratings.sortedBy { it.ordinal },
selectedItems = rating.contentRating,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine( override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
currentState.distinctUntilChangedBy { it.locale }, currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(), getLocalesAsFlow(),
@@ -187,7 +233,32 @@ class FilterCoordinator @Inject constructor(
emptySet() emptySet()
} }
} }
oldValue.copy(tags = newTags) oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
)
}
}
override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tagsExclude + value
} else {
oldValue.tagsExclude - value
}
} else {
if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
}
oldValue.copy(
tagsExclude = newTags,
tags = oldValue.tags - newTags
)
} }
} }
@@ -202,6 +273,17 @@ class FilterCoordinator @Inject constructor(
} }
} }
override fun setContentRating(value: ContentRating, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newRating = if (addOrRemove) {
oldValue.contentRating + value
} else {
oldValue.contentRating - value
}
oldValue.copy(contentRating = newRating)
}
}
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy( oldValue.copy(
@@ -224,13 +306,16 @@ class FilterCoordinator @Inject constructor(
fun setTags(tags: Set<MangaTag>) { fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(tags = tags) oldValue.copy(
tags = tags,
tagsExclude = oldValue.tagsExclude - tags
)
} }
} }
fun reset() { fun reset() {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(oldValue.sortOrder, emptySet(), null, emptySet()) MangaListFilter.Advanced.Builder(oldValue.sortOrder).build()
} }
} }
@@ -248,17 +333,6 @@ class FilterCoordinator @Inject constructor(
) )
} }
private fun getTagsAsFlow() = flow {
val localTags = localTags.get()
emit(PendingData(localTags, isLoading = true, error = null))
tryLoadTags()
.onSuccess { remoteTags ->
emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
}.onFailure {
emit(PendingData(localTags, isLoading = false, error = it))
}
}
private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow { private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow {
emit(PendingData(emptySet(), isLoading = true, error = null)) emit(PendingData(emptySet(), isLoading = true, error = null))
tryLoadLocales() tryLoadLocales()
@@ -277,7 +351,18 @@ class FilterCoordinator @Inject constructor(
searchRepository.getTagsSuggestion(it).take(limit) searchRepository.getTagsSuggestion(it).take(limit)
} }
}, },
getTagsAsFlow(), tagsFlow,
) { suggested, all ->
val res = suggested.toMutableList()
if (res.size < limit) {
res.addAll(all.items.shuffled().take(limit - res.size))
}
PendingData(res, all.isLoading, all.error.takeIf { res.size < limit })
}
private fun getBottomTagsAsFlow(limit: Int): Flow<PendingData<MangaTag>> = combine(
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
tagsFlow,
) { suggested, all -> ) { suggested, all ->
val res = suggested.toMutableList() val res = suggested.toMutableList()
if (res.size < limit) { if (res.size < limit) {
@@ -411,6 +496,8 @@ class FilterCoordinator @Inject constructor(
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null) private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
private fun <T> emptyProperty() = FilterProperty<T>(emptyList(), emptySet(), false, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) } private val collator = lc?.let { Collator.getInstance(Locale(it)) }

View File

@@ -37,7 +37,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag val tag = data as? MangaTag
if (tag == null) { if (tag == null) {
TagsCatalogSheet.show(parentFragmentManager) TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
} else { } else {
filter.setTag(tag, chip.isChecked) filter.setTag(tag, chip.isChecked)
} }

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -15,10 +16,14 @@ interface MangaFilter : OnFilterChangedListener {
val filterTags: StateFlow<FilterProperty<MangaTag>> val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<SortOrder>> val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
val filterState: StateFlow<FilterProperty<MangaState>> val filterState: StateFlow<FilterProperty<MangaState>>
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
val filterLocale: StateFlow<FilterProperty<Locale?>> val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel> val header: StateFlow<FilterHeaderModel>

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -14,5 +15,9 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun setTag(value: MangaTag, addOrRemove: Boolean) fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setTagExcluded(value: MangaTag, addOrRemove: Boolean)
fun setState(value: MangaState, addOrRemove: Boolean) fun setState(value: MangaState, addOrRemove: Boolean)
fun setContentRating(value: ContentRating, addOrRemove: Boolean)
} }

View File

@@ -18,12 +18,14 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -31,8 +33,9 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class FilterSheetFragment : class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener { AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
@@ -50,12 +53,16 @@ class FilterSheetFragment :
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
binding.spinnerLocale.onItemSelectedListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this binding.chipsState.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
} }
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
@@ -72,8 +79,14 @@ class FilterSheetFragment :
val filter = requireFilter() val filter = requireFilter()
when (data) { when (data) {
is MangaState -> filter.setState(data, chip.isChecked) is MangaState -> filter.setState(data, chip.isChecked)
is MangaTag -> filter.setTag(data, chip.isChecked) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
null -> TagsCatalogSheet.show(childFragmentManager) filter.setTagExcluded(data, chip.isChecked)
} else {
filter.setTag(data, chip.isChecked)
}
is ContentRating -> filter.setContentRating(data, chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager, chip.parentView?.id == R.id.chips_genresExclude)
} }
} }
@@ -166,6 +179,51 @@ class FilterSheetFragment :
b.chipsGenres.setChips(chips) b.chipsGenres.setChips(chips)
} }
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.textViewGenresExcludeTitle.isGone = value.isEmpty()
b.chipsGenresExclude.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
value.selectedItems.mapTo(chips) { tag ->
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = true,
data = tag,
)
}
value.availableItems.mapNotNullTo(chips) { tag ->
if (tag !in value.selectedItems) {
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = false,
data = tag,
)
} else {
null
}
}
chips.add(
ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
data = null,
),
)
b.chipsGenresExclude.setChips(chips)
}
private fun onStateChanged(value: FilterProperty<MangaState>) { private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.textViewStateTitle.isGone = value.isEmpty() b.textViewStateTitle.isGone = value.isEmpty()
@@ -186,6 +244,26 @@ class FilterSheetFragment :
b.chipsState.setChips(chips) b.chipsState.setChips(chips)
} }
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
val b = viewBinding ?: return
b.textViewContentRatingTitle.isGone = value.isEmpty()
b.chipsContentRating.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel(
tint = 0,
title = getString(contentRating.titleResId),
icon = 0,
isCheckable = true,
isChecked = contentRating in value.selectedItems,
data = contentRating,
)
}
b.chipsContentRating.setChips(chips)
}
private fun requireFilter() = (requireActivity() as FilterOwner).filter private fun requireFilter() = (requireActivity() as FilterOwner).filter
companion object { companion object {

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetTagsBinding import org.koitharu.kotatsu.databinding.SheetTagsBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@@ -30,7 +31,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
private val viewModel by viewModels<TagsCatalogViewModel>( private val viewModel by viewModels<TagsCatalogViewModel>(
extrasProducer = { extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory -> defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create((requireActivity() as FilterOwner).filter) factory.create(
filter = (requireActivity() as FilterOwner).filter,
isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE),
)
} }
}, },
) )
@@ -54,8 +58,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
} }
override fun onItemClick(item: TagCatalogItem, view: View) { override fun onItemClick(item: TagCatalogItem, view: View) {
val filter = (requireActivity() as FilterOwner).filter viewModel.handleTagClick(item.tag, item.isChecked)
filter.setTag(item.tag, !item.isChecked)
} }
override fun onFocusChange(v: View?, hasFocus: Boolean) { override fun onFocusChange(v: View?, hasFocus: Boolean) {
@@ -90,7 +93,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
companion object { companion object {
private const val TAG = "TagsCatalogSheet" private const val TAG = "TagsCatalogSheet"
private const val ARG_EXCLUDE = "exclude"
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG) fun show(fm: FragmentManager, isExcludeTag: Boolean) = TagsCatalogSheet().withArgs(1) {
putBoolean(ARG_EXCLUDE, isExcludeTag)
}.showDistinct(fm, TAG)
} }
} }

View File

@@ -8,29 +8,32 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaTag
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor( class TagsCatalogViewModel @AssistedInject constructor(
@Assisted filter: MangaFilter, @Assisted private val filter: MangaFilter,
mangaRepositoryFactory: MangaRepository.Factory, @Assisted private val isExcluded: Boolean,
dataRepository: MangaDataRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val searchQuery = MutableStateFlow("") val searchQuery = MutableStateFlow("")
private val filterProperty: StateFlow<FilterProperty<MangaTag>>
get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags
private val tags = combine( private val tags = combine(
filter.allTags, filter.allTags,
filter.filterTags.map { it.selectedItems }, filterProperty.map { it.selectedItems },
) { all, selected -> ) { all, selected ->
all.map { x -> all.map { x ->
if (x is TagCatalogItem) { if (x is TagCatalogItem) {
@@ -52,9 +55,17 @@ class TagsCatalogViewModel @AssistedInject constructor(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
fun handleTagClick(tag: MangaTag, isChecked: Boolean) {
if (isExcluded) {
filter.setTagExcluded(tag, !isChecked)
} else {
filter.setTag(tag, !isChecked)
}
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(filter: MangaFilter): TagsCatalogViewModel fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel
} }
} }

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.history.data package org.koitharu.kotatsu.history.data
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.* import java.time.Instant
fun HistoryEntity.toMangaHistory() = MangaHistory( fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt), createdAt = Instant.ofEpochMilli(createdAt),
updatedAt = Date(updatedAt), updatedAt = Instant.ofEpochMilli(updatedAt),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll.toInt(), scroll = scroll.toInt(),
percent = percent, percent = percent,
) )

View File

@@ -8,9 +8,10 @@ import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.startOfDay import java.time.Instant
import java.util.Date import java.time.LocalDate
import java.util.concurrent.TimeUnit import java.time.ZoneId
import java.time.temporal.ChronoUnit
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class HistoryListMenuProvider( class HistoryListMenuProvider(
@@ -50,9 +51,9 @@ class HistoryListMenuProvider(
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ -> .setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) { val minDate = when (selectionListener.selection) {
0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) 0 -> Instant.now().minus(2, ChronoUnit.HOURS)
1 -> Date().startOfDay() 1 -> LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()
2 -> 0L 2 -> Instant.EPOCH
else -> return@setPositiveButton else -> return@setPositiveButton
} }
viewModel.clearHistory(minDate) viewModel.clearHistory(minDate)

View File

@@ -19,10 +19,9 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
@@ -40,8 +39,7 @@ import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.Date import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -100,13 +98,13 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
fun clearHistory(minDate: Long) { fun clearHistory(minDate: Instant) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) { val stringRes = if (minDate <= Instant.EPOCH) {
repository.clear() repository.clear()
R.string.history_cleared R.string.history_cleared
} else { } else {
repository.deleteAfter(minDate) repository.deleteAfter(minDate.toEpochMilli())
R.string.removed_from_history R.string.removed_from_history
} }
onActionDone.call(ReversibleAction(stringRes, null)) onActionDone.call(ReversibleAction(stringRes, null))
@@ -165,8 +163,8 @@ class HistoryListViewModel @Inject constructor(
} }
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) { private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
ListSortOrder.UPDATED -> ListHeader(timeAgo(updatedAt)) ListSortOrder.UPDATED -> ListHeader(calculateTimeAgo(updatedAt))
ListSortOrder.NEWEST -> ListHeader(timeAgo(createdAt)) ListSortOrder.NEWEST -> ListHeader(calculateTimeAgo(createdAt))
ListSortOrder.PROGRESS -> ListHeader( ListSortOrder.PROGRESS -> ListHeader(
when (percent) { when (percent) {
1f -> R.string.status_completed 1f -> R.string.status_completed
@@ -181,18 +179,4 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.NEW_CHAPTERS, ListSortOrder.NEW_CHAPTERS,
ListSortOrder.RATING -> null ListSortOrder.RATING -> null
} }
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
diffDays < 200 -> DateTimeAgo.MonthsAgo(diffDays / 30)
else -> DateTimeAgo.LongAgo
}
}
} }

View File

@@ -171,6 +171,9 @@ abstract class MangaListFragment :
private suspend fun onListChanged(list: List<ListModel>) { private suspend fun onListChanged(list: List<ListModel>) {
listAdapter?.emit(list) listAdapter?.emit(list)
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
viewBinding?.recyclerView?.let {
paginationListener?.postInvalidate(it)
}
} }
private fun resolveException(e: Throwable) { private fun resolveException(e: Throwable) {

View File

@@ -14,22 +14,37 @@ import com.google.android.material.R as materialR
@CheckResult @CheckResult
fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
return if (counter > 0) { return bindBadgeImpl(badge, null, counter)
val badgeDrawable = badge ?: initBadge(this) }
badgeDrawable.number = counter
badgeDrawable.isVisible = true @CheckResult
badgeDrawable.align(this) fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? {
badgeDrawable return bindBadgeImpl(badge, text, 0)
} else {
badge?.isVisible = false
badge
}
} }
fun View.clearBadge(badge: BadgeDrawable?) { fun View.clearBadge(badge: BadgeDrawable?) {
BadgeUtils.detachBadgeDrawable(badge, this) BadgeUtils.detachBadgeDrawable(badge, this)
} }
private fun View.bindBadgeImpl(
badge: BadgeDrawable?,
text: String?,
counter: Int,
): BadgeDrawable? = if (text != null || counter > 0) {
val badgeDrawable = badge ?: initBadge(this)
if (counter > 0) {
badgeDrawable.number = counter
} else {
badgeDrawable.text = text?.takeUnless { it.isEmpty() }
}
badgeDrawable.isVisible = true
badgeDrawable.align(this)
badgeDrawable
} else {
badge?.isVisible = false
badge
}
private fun initBadge(anchor: View): BadgeDrawable { private fun initBadge(anchor: View): BadgeDrawable {
val badge = BadgeDrawable.create(anchor.context) val badge = BadgeDrawable.create(anchor.context)
val resources = anchor.resources val resources = anchor.resources

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -12,6 +13,8 @@ fun listHeaderAD(
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>( ) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>(
{ inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null
if (listener != null) { if (listener != null) {
binding.buttonMore.setOnClickListener { binding.buttonMore.setOnClickListener {
listener.onListHeaderClick(item, it) listener.onListHeaderClick(item, it)
@@ -23,9 +26,11 @@ fun listHeaderAD(
if (item.buttonTextRes == 0) { if (item.buttonTextRes == 0) {
binding.buttonMore.isInvisible = true binding.buttonMore.isInvisible = true
binding.buttonMore.text = null binding.buttonMore.text = null
binding.buttonMore.clearBadge(badge)
} else { } else {
binding.buttonMore.setText(item.buttonTextRes) binding.buttonMore.setText(item.buttonTextRes)
binding.buttonMore.isVisible = true binding.buttonMore.isVisible = true
badge = itemView.bindBadge(badge, item.badge)
} }
} }
} }

View File

@@ -6,38 +6,41 @@ import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
@Suppress("DataClassPrivateConstructor") @Suppress("DataClassPrivateConstructor")
data class ListHeader private constructor( data class ListHeader private constructor(
private val text: CharSequence?, private val textRaw: Any,
@StringRes private val textRes: Int,
private val dateTimeAgo: DateTimeAgo?,
@StringRes val buttonTextRes: Int, @StringRes val buttonTextRes: Int,
val payload: Any?, val payload: Any?,
val badge: String?,
) : ListModel { ) : ListModel {
constructor( constructor(
text: CharSequence, text: CharSequence,
@StringRes buttonTextRes: Int = 0, @StringRes buttonTextRes: Int = 0,
payload: Any? = null, payload: Any? = null,
) : this(text, 0, null, buttonTextRes, payload) badge: String? = null,
) : this(textRaw = text, buttonTextRes, payload, badge)
constructor( constructor(
@StringRes textRes: Int, @StringRes textRes: Int,
@StringRes buttonTextRes: Int = 0, @StringRes buttonTextRes: Int = 0,
payload: Any? = null, payload: Any? = null,
) : this(null, textRes, null, buttonTextRes, payload) badge: String? = null,
) : this(textRaw = textRes, buttonTextRes, payload, badge)
constructor( constructor(
dateTimeAgo: DateTimeAgo, dateTimeAgo: DateTimeAgo,
@StringRes buttonTextRes: Int = 0, @StringRes buttonTextRes: Int = 0,
payload: Any? = null, payload: Any? = null,
) : this(null, 0, dateTimeAgo, buttonTextRes, payload) badge: String? = null,
) : this(textRaw = dateTimeAgo, buttonTextRes, payload, badge)
fun getText(context: Context): CharSequence? = when { fun getText(context: Context): CharSequence? = when (textRaw) {
text != null -> text is CharSequence -> textRaw
textRes != 0 -> context.getString(textRes) is Int -> if (textRaw != 0) context.getString(textRaw) else null
else -> dateTimeAgo?.format(context.resources) is DateTimeAgo -> textRaw.format(context.resources)
else -> null
} }
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is ListHeader && text == other.text && dateTimeAgo == other.dateTimeAgo && textRes == other.textRes return other is ListHeader && textRaw == other.textRaw
} }
} }

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -52,8 +53,11 @@ class LocalMangaRepository @Inject constructor(
private val locks = CompositeMutex2<Long>() private val locks = CompositeMutex2<Long>()
override val isMultipleTagsSupported: Boolean = true override val isMultipleTagsSupported: Boolean = true
override val isTagsExclusionSupported: Boolean = true
override val isSearchSupported: Boolean = true
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>() override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = settings.localListOrder get() = settings.localListOrder
@@ -75,6 +79,9 @@ class LocalMangaRepository @Inject constructor(
if (filter.tags.isNotEmpty()) { if (filter.tags.isNotEmpty()) {
list.retainAll { x -> x.containsTags(filter.tags) } list.retainAll { x -> x.containsTags(filter.tags) }
} }
if (filter.tagsExclude.isNotEmpty()) {
list.removeAll { x -> x.containsAnyTag(filter.tags) }
}
when (filter.sortOrder) { when (filter.sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.RATING -> list.sortByDescending { it.manga.rating }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.StatFs import android.os.StatFs
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -10,15 +11,17 @@ import kotlinx.coroutines.withContext
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.use
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -66,6 +69,16 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
} }
} }
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try {
bitmap.compressToPNG(file)
lruCache.get().put(url, file)
} finally {
file.delete()
}
}
private suspend fun getAvailableSize(): Long = runCatchingCancellable { private suspend fun getAvailableSize(): Long = runCatchingCancellable {
val statFs = StatFs(cacheDir.get().absolutePath) val statFs = StatFs(cacheDir.get().absolutePath)
statFs.availableBytes statFs.availableBytes

View File

@@ -30,6 +30,12 @@ data class LocalManga(
return manga.tags.containsAll(tags) return manga.tags.containsAll(tags)
} }
fun containsAnyTag(tags: Set<MangaTag>): Boolean {
return tags.any { tag ->
manga.tags.contains(tag)
}
}
override fun toString(): String { override fun toString(): String {
return "LocalManga(${file.path}: ${manga.title})" return "LocalManga(${file.path}: ${manga.title})"
} }

View File

@@ -125,7 +125,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null))
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged) viewModel.feedCounter.observe(this, ::onFeedCounterChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this)) viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) { viewModel.onFirstStart.observeEvent(this) {
WelcomeSheet.show(supportFragmentManager) WelcomeSheet.show(supportFragmentManager)
@@ -278,10 +278,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
startActivity(IntentBuilder(this).manga(manga).build(), options) startActivity(IntentBuilder(this).manga(manga).build(), options)
} }
private fun onCountersChanged(counters: Map<NavItem, Int>) { private fun onFeedCounterChanged(counter: Int) {
counters.forEach { (navItem, counter) -> navigationDelegate.setCounter(NavItem.FEED, counter)
navigationDelegate.setCounter(navItem, counter)
}
} }
private fun onIncognitoModeChanged(isIncognito: Boolean) { private fun onIncognitoModeChanged(isIncognito: Boolean) {

View File

@@ -4,15 +4,11 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
@@ -22,7 +18,6 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -52,19 +47,8 @@ class MainViewModel @Inject constructor(
val appUpdate = appUpdateRepository.observeAvailableUpdate() val appUpdate = appUpdateRepository.observeAvailableUpdate()
val counters = combine( val feedCounter = trackingRepository.observeUpdatedMangaCount()
trackingRepository.observeUpdatedMangaCount(), .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, 0)
observeNewSourcesCount(),
) { tracks, newSources ->
val em = EnumMap<NavItem, Int>(NavItem::class.java)
em[NavItem.EXPLORE] = newSources
em[NavItem.FEED] = tracks
em
}.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyMap<NavItem, Int>(),
)
init { init {
launchJob { launchJob {
@@ -87,8 +71,4 @@ class MainViewModel @Inject constructor(
fun setIncognitoMode(isEnabled: Boolean) { fun setIncognitoMode(isEnabled: Boolean) {
settings.isIncognitoModeEnabled = isEnabled settings.isIncognitoModeEnabled = isEnabled
} }
private fun observeNewSourcesCount() = sourcesRepository.observeNewSources()
.map { if (sourcesRepository.isSetupRequired()) 0 else it.size }
.distinctUntilChanged()
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.contains
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages { class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages {
@@ -57,6 +58,8 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
return pages.subList(range.first, range.last + 1) return pages.subList(range.first, range.last + 1)
} }
operator fun contains(chapterId: Long) = indices.contains(chapterId)
private fun shiftIndices(delta: Int) { private fun shiftIndices(delta: Int) {
for (i in 0 until indices.size()) { for (i in 0 until indices.size()) {
val range = indices.valueAt(i) val range = indices.valueAt(i)

View File

@@ -67,6 +67,10 @@ class ChaptersLoader @Inject constructor(
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId] fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
fun hasPages(chapterId: Long): Boolean {
return chapterId in chapterPages
}
fun getPages(chapterId: Long): List<ReaderPage> { fun getPages(chapterId: Long): List<ReaderPage> {
return chapterPages.subList(chapterId) return chapterPages.subList(chapterId)
} }

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -26,6 +25,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.use
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -35,6 +35,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast
import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.exists import org.koitharu.kotatsu.core.util.ext.exists
import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull
@@ -49,10 +51,11 @@ import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.io.File
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
import kotlin.concurrent.Volatile
import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -65,11 +68,7 @@ class PageLoader @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor, private val imageProxyInterceptor: ImageProxyInterceptor,
) : RetainedLifecycle.OnClearedListener { ) {
init {
lifecycle.addOnClearedListener(this)
}
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
@@ -77,17 +76,13 @@ class PageLoader @Inject constructor(
private val semaphore = Semaphore(3) private val semaphore = Semaphore(3)
private val convertLock = Mutex() private val convertLock = Mutex()
private val prefetchLock = Mutex() private val prefetchLock = Mutex()
@Volatile
private var repository: MangaRepository? = null private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>() private val prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0) private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
override fun onCleared() {
synchronized(tasks) {
tasks.clear()
}
}
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository return repository is RemoteMangaRepository
&& settings.isPagesPreloadEnabled && settings.isPagesPreloadEnabled
@@ -131,21 +126,30 @@ class PageLoader @Inject constructor(
return loadPageAsync(page, force).await() return loadPageAsync(page, force).await()
} }
suspend fun convertInPlace(file: File) { suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock {
convertLock.withLock { if (uri.isZipUri()) {
if (context.ramAvailable < file.length() * 2) { val bitmap = runInterruptible(Dispatchers.IO) {
return@withLock ZipFile(uri.schemeSpecificPart).use { zip ->
} val entry = zip.getEntry(uri.fragment)
runInterruptible(Dispatchers.Default) { context.ensureRamAtLeast(entry.size * 2)
val image = BitmapFactory.decodeFile(file.absolutePath) zip.getInputStream(zip.getEntry(uri.fragment)).use {
try { BitmapFactory.decodeStream(it)
file.outputStream().use { out ->
image.compress(Bitmap.CompressFormat.PNG, 100, out)
} }
} finally {
image.recycle()
} }
} }
cache.put(uri.toString(), bitmap).toUri()
} else {
val file = uri.toFile()
context.ensureRamAtLeast(file.length() * 2)
val image = runInterruptible(Dispatchers.IO) {
BitmapFactory.decodeFile(file.absolutePath)
}
try {
image.compressToPNG(file)
} finally {
image.recycle()
}
uri
} }
} }
@@ -237,7 +241,7 @@ class PageLoader @Inject constructor(
companion object { companion object {
private const val PROGRESS_UNDEFINED = -1f private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10 private const val PREFETCH_LIMIT_DEFAULT = 6
private const val PREFETCH_MIN_RAM_MB = 80L private const val PREFETCH_MIN_RAM_MB = 80L
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder() fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()

View File

@@ -24,8 +24,9 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import java.text.SimpleDateFormat import java.time.LocalTime
import java.util.Date import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class ReaderInfoBarView @JvmOverloads constructor( class ReaderInfoBarView @JvmOverloads constructor(
@@ -36,7 +37,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect() private val textBounds = Rect()
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT) private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val timeReceiver = TimeReceiver() private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0 private var insetLeft: Int = 0
private var insetRight: Int = 0 private var insetRight: Int = 0
@@ -52,7 +53,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
200, 200,
) )
private var timeText = timeFormat.format(Date()) private var timeText = timeFormat.format(LocalTime.now())
private var text: String = "" private var text: String = ""
private val innerHeight private val innerHeight
@@ -181,7 +182,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
private inner class TimeReceiver : BroadcastReceiver() { private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
timeText = timeFormat.format(Date()) timeText = timeFormat.format(LocalTime.now())
invalidate() invalidate()
} }
} }

View File

@@ -56,7 +56,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import java.util.Date import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
@@ -302,7 +302,7 @@ class ReaderViewModel @Inject constructor(
page = state.page, page = state.page,
scroll = state.scroll, scroll = state.scroll,
imageUrl = page.preview.ifNullOrEmpty { page.url }, imageUrl = page.preview.ifNullOrEmpty { page.url },
createdAt = Date(), createdAt = Instant.now(),
percent = computePercent(state.chapterId, state.page), percent = computePercent(state.chapterId, state.page),
) )
bookmarksRepository.addBookmark(bookmark) bookmarksRepository.addBookmark(bookmark)

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -139,10 +137,9 @@ class PageHolderDelegate(
prevJob?.join() prevJob?.join()
state = State.CONVERTING state = State.CONVERTING
try { try {
val file = uri.toFile() val newUri = loader.convertBimap(uri)
loader.convertInPlace(file)
state = State.CONVERTED state = State.CONVERTED
callback.onImageReady(file.toUri()) callback.onImageReady(newUri)
} catch (ce: CancellationException) { } catch (ce: CancellationException) {
throw ce throw ce
} catch (e2: Throwable) { } catch (e2: Throwable) {

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.reader.ui.thumbnails package org.koitharu.kotatsu.reader.ui.thumbnails
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
data class PageThumbnail( data class PageThumbnail(
val isCurrent: Boolean, val isCurrent: Boolean,
val repository: MangaRepository,
val page: ReaderPage, val page: ReaderPage,
) : ListModel { ) : ListModel {

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
@@ -26,7 +25,6 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PagesThumbnailsViewModel @Inject constructor( class PagesThumbnailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
detailsLoadUseCase: DetailsLoadUseCase, detailsLoadUseCase: DetailsLoadUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
@@ -36,14 +34,13 @@ class PagesThumbnailsViewModel @Inject constructor(
private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L
val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga
private val repository = mangaRepositoryFactory.create(manga.source)
private val mangaDetails = detailsLoadUseCase(MangaIntent.of(manga)).map { private val mangaDetails = detailsLoadUseCase(MangaIntent.of(manga)).map {
val b = manga.chapters?.findById(initialChapterId)?.branch val b = manga.chapters?.findById(initialChapterId)?.branch
branch.value = b branch.value = b
it.filterChapters(b) it.filterChapters(b)
}.withErrorHandling() }.withErrorHandling()
.stateIn(viewModelScope, SharingStarted.Lazily, null) .stateIn(viewModelScope, SharingStarted.Lazily, null)
private var loadingJob: Job? = null private var loadingJob: Job
private var loadingPrevJob: Job? = null private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null private var loadingNextJob: Job? = null
@@ -59,14 +56,14 @@ class PagesThumbnailsViewModel @Inject constructor(
} }
fun loadPrevChapter() { fun loadPrevChapter() {
if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { if (loadingJob.isActive || loadingPrevJob?.isActive == true) {
return return
} }
loadingPrevJob = loadPrevNextChapter(isNext = false) loadingPrevJob = loadPrevNextChapter(isNext = false)
} }
fun loadNextChapter() { fun loadNextChapter() {
if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { if (loadingJob.isActive || loadingNextJob?.isActive == true) {
return return
} }
loadingNextJob = loadPrevNextChapter(isNext = true) loadingNextJob = loadPrevNextChapter(isNext = true)
@@ -91,7 +88,6 @@ class PagesThumbnailsViewModel @Inject constructor(
} }
this += PageThumbnail( this += PageThumbnail(
isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex, isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex,
repository = repository,
page = page, page = page,
) )
} }

View File

@@ -11,6 +11,8 @@ import androidx.core.view.MenuProvider
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.drop
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
@@ -44,6 +46,11 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { viewModel.onOpenManga.observeEvent(viewLifecycleOwner) {
startActivity(DetailsActivity.newIntent(binding.root.context, it)) startActivity(DetailsActivity.newIntent(binding.root.context, it))
} }
viewModel.header.distinctUntilChangedBy { it.isFilterApplied }
.drop(1)
.observe(viewLifecycleOwner) {
activity?.invalidateMenu()
}
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
@@ -94,12 +101,18 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
true true
} }
R.id.action_filter_reset -> {
viewModel.resetFilter()
true
}
else -> false else -> false
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu) super.onPrepareMenu(menu)
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied
} }
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {

View File

@@ -28,8 +28,9 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val DOMAIN = "shikimori.one"
private const val REDIRECT_URI = "kotatsu://shikimori-auth" private const val REDIRECT_URI = "kotatsu://shikimori-auth"
private const val BASE_URL = "https://shikimori.me/" private const val BASE_URL = "https://$DOMAIN/"
private const val MANGA_PAGE_SIZE = 10 private const val MANGA_PAGE_SIZE = 10
@Singleton @Singleton
@@ -199,15 +200,15 @@ class ShikimoriRepository @Inject constructor(
id = json.getLong("id"), id = json.getLong("id"),
name = json.getString("name"), name = json.getString("name"),
altName = json.getStringOrNull("russian"), altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"), cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl("shikimori.me"), url = json.getString("url").toAbsoluteUrl(DOMAIN),
) )
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"), id = json.getLong("id"),
name = json.getString("name"), name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"), cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl("shikimori.me"), url = json.getString("url").toAbsoluteUrl(DOMAIN),
descriptionHtml = json.getString("description_html"), descriptionHtml = json.getString("description_html"),
) )

View File

@@ -111,6 +111,10 @@ class MangaSearchRepository @Inject constructor(
} }
} }
suspend fun getRareTags(source: MangaSource, limit: Int): List<MangaTag> {
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
}
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> { fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
if (query.length < 3) { if (query.length < 3) {
return emptyList() return emptyList()

View File

@@ -110,11 +110,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
if (account == null) { if (account == null) {
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
} else { } else {
try { startActivitySafe(SyncSettingsIntent(account))
startActivity(SyncSettingsIntent(account))
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
} }
true true
} }

View File

@@ -83,7 +83,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private fun openLink(url: String, title: CharSequence?) { private fun openLink(url: String, title: CharSequence?) {
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUri() intent.data = url.toUri()
startActivity( startActivitySafe(
if (title != null) { if (title != null) {
Intent.createChooser(intent, title) Intent.createChooser(intent, title)
} else { } else {

View File

@@ -10,8 +10,10 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -31,6 +34,8 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private var newSourcesSnackbar: Snackbar? = null
override val appBar: AppBarLayout override val appBar: AppBarLayout
get() = viewBinding.appbar get() = viewBinding.appbar
@@ -45,6 +50,7 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter) val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter)
tabMediator.attach() tabMediator.attach()
viewModel.content.observe(this, pagerAdapter) viewModel.content.observe(this, pagerAdapter)
viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged)
viewModel.onActionDone.observeEvent( viewModel.onActionDone.observeEvent(
this, this,
ReversibleActionObserver(viewBinding.pager), ReversibleActionObserver(viewBinding.pager),
@@ -80,4 +86,31 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
viewModel.performSearch(null) viewModel.performSearch(null)
return true return true
} }
private fun onHasNewSourcesChanged(hasNewSources: Boolean) {
if (hasNewSources) {
if (newSourcesSnackbar?.isShownOrQueued == true) {
return
}
val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE)
snackbar.setAction(R.string.explore) {
NewSourcesDialogFragment.show(supportFragmentManager)
}
snackbar.addCallback(
object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (event == DISMISS_EVENT_SWIPE) {
viewModel.skipNewSources()
}
}
},
)
snackbar.show()
newSourcesSnackbar = snackbar
} else {
newSourcesSnackbar?.dismiss()
newSourcesSnackbar = null
}
}
} }

View File

@@ -41,6 +41,10 @@ class SourcesCatalogViewModel @Inject constructor(
val locales = repository.allMangaSources.mapToSet { it.locale } val locales = repository.allMangaSources.mapToSet { it.locale }
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
val hasNewSources = repository.observeNewSources()
.map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
private val listProducers = locale.map { lc -> private val listProducers = locale.map { lc ->
createListProducers(lc) createListProducers(lc)
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
@@ -71,6 +75,12 @@ class SourcesCatalogViewModel @Inject constructor(
} }
} }
fun skipNewSources() {
launchJob {
repository.assimilateNewSources()
}
}
@MainThread @MainThread
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> { private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
val types = EnumSet.allOf(ContentType::class.java) val types = EnumSet.allOf(ContentType::class.java)

View File

@@ -84,14 +84,14 @@ class TrackerSettingsFragment :
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
startActivity(intent) startActivitySafe(intent)
true true
} }
channels.areNotificationsDisabled -> { channels.areNotificationsDisabled -> {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", requireContext().packageName, null)) .setData(Uri.fromParts("package", requireContext().packageName, null))
startActivity(intent) startActivitySafe(intent)
true true
} }

View File

@@ -211,12 +211,9 @@ class SuggestionsWorker @AssistedInject constructor(
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced( filter = MangaListFilter.Advanced.Builder(order)
sortOrder = order, .tags(setOfNotNull(tag))
tags = setOfNotNull(tag), .build(),
locale = null,
states = setOf(),
),
).asArrayList() ).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) { if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.tracker.data
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date import java.time.Instant
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem { fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() } val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
@@ -11,7 +11,7 @@ fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): Tracki
id = trackLog.id, id = trackLog.id,
chapters = chaptersList, chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()), manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt), createdAt = Instant.ofEpochMilli(trackLog.createdAt),
isNew = counters.decrement(trackLog.mangaId, chaptersList.size), isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
) )
} }

View File

@@ -6,6 +6,7 @@ import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeMutex2
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
@@ -13,6 +14,8 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.tracker.work.TrackingItem import org.koitharu.kotatsu.tracker.work.TrackingItem
import javax.inject.Inject import javax.inject.Inject
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class Tracker @Inject constructor( class Tracker @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -77,7 +80,10 @@ class Tracker @Inject constructor(
repository.gc() repository.gc()
} }
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates.Success { suspend fun fetchUpdates(
track: MangaTracking,
commit: Boolean
): MangaUpdates.Success = withMangaLock(track.manga.id) {
val repo = mangaRepositoryFactory.create(track.manga.source) val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" } require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY) val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
@@ -99,7 +105,7 @@ class Tracker @Inject constructor(
} }
@VisibleForTesting @VisibleForTesting
suspend fun deleteTrack(mangaId: Long) { suspend fun deleteTrack(mangaId: Long) = withMangaLock(mangaId) {
repository.deleteTrack(mangaId) repository.deleteTrack(mangaId)
} }
@@ -137,4 +143,21 @@ class Tracker @Inject constructor(
} }
} }
} }
private companion object {
private val mangaMutex = CompositeMutex2<Long>()
suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T {
contract {
callsInPlace(action, InvocationKind.EXACTLY_ONCE)
}
mangaMutex.lock(id)
try {
return action()
} finally {
mangaMutex.unlock(id)
}
}
}
} }

View File

@@ -25,7 +25,7 @@ import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -89,7 +89,7 @@ class TrackingRepository @Inject constructor(
result += MangaTracking( result += MangaTracking(
manga = manga, manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date), lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
) )
} }
return result return result
@@ -101,7 +101,7 @@ class TrackingRepository @Inject constructor(
return MangaTracking( return MangaTracking(
manga = manga, manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date), lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
) )
} }

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.tracker.domain.model package org.koitharu.kotatsu.tracker.domain.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
data class MangaTracking( data class MangaTracking(
val manga: Manga, val manga: Manga,
val lastChapterId: Long, val lastChapterId: Long,
val lastCheck: Date?, val lastCheck: Instant?,
) { ) {
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return lastChapterId == 0L return lastChapterId == 0L

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.tracker.domain.model package org.koitharu.kotatsu.tracker.domain.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
data class TrackingLogItem( data class TrackingLogItem(
val id: Long, val id: Long,
val manga: Manga, val manga: Manga,
val chapters: List<String>, val chapters: List<String>,
val createdAt: Date, val createdAt: Instant,
val isNew: Boolean, val isNew: Boolean,
) )

View File

@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -27,8 +27,6 @@ import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader
import org.koitharu.kotatsu.tracker.ui.feed.model.toFeedItem import org.koitharu.kotatsu.tracker.ui.feed.model.toFeedItem
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import java.util.Date
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@@ -99,7 +97,7 @@ class FeedViewModel @Inject constructor(
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) { private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
for (item in this) { for (item in this) {
val date = timeAgo(item.createdAt) val date = calculateTimeAgo(item.createdAt)
if (prevDate != date) { if (prevDate != date) {
destination += ListHeader(date) destination += ListHeader(date)
} }
@@ -115,17 +113,4 @@ class FeedViewModel @Inject constructor(
UpdatedMangaHeader(mangaList.toUi(ListMode.GRID, listExtraProvider)) UpdatedMangaHeader(mangaList.toUi(ListMode.GRID, listExtraProvider))
} }
} }
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.Absolute(date)
}
}
} }

View File

@@ -113,12 +113,25 @@
app:layout_constraintStart_toEndOf="@id/container_details" app:layout_constraintStart_toEndOf="@id/container_details"
app:layout_constraintTop_toBottomOf="@id/appbar"> app:layout_constraintTop_toBottomOf="@id/appbar">
<androidx.fragment.app.FragmentContainerView <LinearLayout
android:id="@+id/container_chapters"
android:name="org.koitharu.kotatsu.details.ui.ChaptersFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:layout="@layout/fragment_chapters" /> android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="@style/Widget.Material3.TabLayout.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
app:tabUnboundedRipple="false" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View File

@@ -98,12 +98,18 @@
</FrameLayout> </FrameLayout>
<androidx.fragment.app.FragmentContainerView <com.google.android.material.tabs.TabLayout
android:id="@+id/container_chapters" android:id="@+id/tabs"
android:name="org.koitharu.kotatsu.details.ui.ChaptersFragment" style="@style/Widget.Material3.TabLayout.Secondary"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
tools:layout="@layout/fragment_chapters" /> android:background="@null"
app:tabUnboundedRipple="false" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout> </LinearLayout>

View File

@@ -12,6 +12,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:orientation="vertical" android:orientation="vertical"
android:scrollIndicators="top"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chapter" /> tools:listitem="@layout/item_chapter" />

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollIndicators="top"
app:bubbleSize="small"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/item_page_thumb"
tools:spanCount="3" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:layout_marginEnd="@dimen/margin_normal"
android:layout_marginBottom="@dimen/margin_normal"
android:gravity="center"
android:text="@string/chapters_empty"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:showAnimationBehavior="inward"
app:trackCornerRadius="0dp"
tools:visibility="visible" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="inward"
app:showAnimationBehavior="outward"
app:trackCornerRadius="0dp"
tools:visibility="visible" />
</FrameLayout>

View File

@@ -17,13 +17,10 @@
<TextView <TextView
android:id="@+id/textView_state" android:id="@+id/textView_state"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center_horizontal"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone" android:visibility="gone"
tools:drawableTopCompat="@drawable/ic_state_finished" tools:drawableTopCompat="@drawable/ic_state_finished"
tools:text="Completed" tools:text="Completed"
@@ -31,13 +28,10 @@
<TextView <TextView
android:id="@+id/textView_chapters" android:id="@+id/textView_chapters"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center_horizontal"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone" android:visibility="gone"
app:drawableTopCompat="@drawable/ic_book_page" app:drawableTopCompat="@drawable/ic_book_page"
tools:text="52 chapters" tools:text="52 chapters"
@@ -45,28 +39,22 @@
<TextView <TextView
android:id="@+id/textView_nsfw" android:id="@+id/textView_nsfw"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:text="@string/nsfw" android:text="@string/nsfw"
android:textSize="12sp"
android:visibility="gone" android:visibility="gone"
app:drawableTopCompat="@drawable/ic_alert_outline" app:drawableTopCompat="@drawable/ic_alert_outline"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <TextView
android:id="@+id/textView_source" android:id="@+id/textView_source"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless" android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone" android:visibility="gone"
app:drawableTopCompat="@drawable/ic_web" app:drawableTopCompat="@drawable/ic_web"
tools:text="Source" tools:text="Source"
@@ -74,13 +62,10 @@
<TextView <TextView
android:id="@+id/textView_size" android:id="@+id/textView_size"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone" android:visibility="gone"
app:drawableTopCompat="@drawable/ic_storage" app:drawableTopCompat="@drawable/ic_storage"
tools:text="1.8 GiB" tools:text="1.8 GiB"

View File

@@ -126,6 +126,28 @@
tools:text="@string/error_multiple_genres_not_supported" tools:text="@string/error_multiple_genres_not_supported"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView
android:id="@+id/textView_genresExclude_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/genres_exclude"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:visibility="gone"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/textView_state_title" android:id="@+id/textView_state_title"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -148,6 +170,28 @@
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView
android:id="@+id/textView_contentRating_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true"
android:text="@string/content_rating"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:paddingHorizontal="@dimen/margin_normal"
android:visibility="gone"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
tools:visibility="visible" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu <menu
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item <item
android:id="@+id/action_search" android:id="@+id/action_search"
@@ -22,6 +23,14 @@
android:title="@string/filter" android:title="@string/filter"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_filter_reset"
android:orderInCategory="30"
android:title="@string/reset_filter"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item <item
android:id="@+id/action_source_settings" android:id="@+id/action_source_settings"
android:orderInCategory="50" android:orderInCategory="50"

View File

@@ -181,4 +181,106 @@
<string name="text_history_holder_primary">كل ما تقرأه سيعرض هنا</string> <string name="text_history_holder_primary">كل ما تقرأه سيعرض هنا</string>
<string name="text_search_holder_secondary">حاول إعادة صياغة الكلمات.</string> <string name="text_search_holder_secondary">حاول إعادة صياغة الكلمات.</string>
<string name="text_empty_holder_primary">يبدو أنه فارغ…</string> <string name="text_empty_holder_primary">يبدو أنه فارغ…</string>
<string name="status_re_reading">إعادة القراءة</string>
<string name="detect_reader_mode">وضع القارئ التلقائي</string>
<string name="manga_shelf">رف</string>
<string name="tracking">تتبع</string>
<string name="text_history_holder_secondary">ابحث عن ما تقرأه في قسم «استكشاف».</string>
<string name="all_favourites">جميع المفضلة</string>
<string name="email_enter_hint">أدخل بريدك الإلكتروني للمتابعة</string>
<string name="disable_all">تعطيل الجميع</string>
<string name="text_shelf_holder_secondary">ابحث عن ما تقرأه في خانة «استكشاف».</string>
<string name="chapters_empty">لا توجد فصول في هذه المانجا</string>
<string name="preload_pages">إعادة تحميل الصفحات</string>
<string name="show_reading_indicators">إظهار مؤشرات التقدم في القراءة</string>
<string name="local_manga_processing">معالجة المانجا المحفوظة</string>
<string name="cannot_find_available_storage">لاتوجد مساحة تخزين كافية</string>
<string name="show_notification_new_chapters_off">"لن تتلقى إشعارات ولكن سيتم تمييز الفصول الجديدة في القوائم"</string>
<string name="favourites_category_empty">فئة فارغة</string>
<string name="show_notification_new_chapters_on">ستتلقى إشعارات حول تحديثات المانجا التي تقرأها</string>
<string name="manga_save_location">مجلد للتحميلات</string>
<string name="status_reading">أقرأها</string>
<string name="auth_complete">مصرح له</string>
<string name="various_languages">لغات مختلفة</string>
<string name="removal_completed">اكتملت عملية الإزالة</string>
<string name="edit">تعديل</string>
<string name="captcha_required">مطلوب التحقق من الCAPTCHA</string>
<string name="filter_load_error">غير قادر على تحميل قائمة الأنواع</string>
<string name="removed_from_history">تمت الحذف من السجل</string>
<string name="crash_text">"حدث خطأ ما. يرجى إرسال تقرير بالخطأ إلى المطورين لمساعدتنا في إصلاحه."</string>
<string name="detect_reader_mode_summary">اكتشف تلقائيًا ما إذا كانت المانجا عبارة عن webtoon</string>
<string name="appwidget_recent_description">المانجا التي قرأتها مؤخرًا</string>
<string name="appearance">مظهر</string>
<string name="bookmark_remove">حذف من المحفظة</string>
<string name="disable_battery_optimization_summary">يساعد في فحص التحديثات في الخلفية</string>
<string name="auth_not_supported_by">تسجيل الدخول على %s غير مدعوم</string>
<string name="status_on_hold">معلقَّة</string>
<string name="name">اسم</string>
<string name="edit_category">تغيير الفئة</string>
<string name="tracker_warning">تتميز بعض الأجهزة بسلوك نظام مختلف، مما قد يؤدي إلى تعطيل مهام الخلفية.</string>
<string name="suggestions_excluded_genres_summary">حدد الأنواع التي لا تريد رؤيتها في الاقتراحات</string>
<string name="scale_mode">وضع القياس</string>
<string name="only_using_wifi">فقط في حالة توفر خدمة الوايفاي</string>
<string name="black_dark_theme">أسود</string>
<string name="back">رجوع</string>
<string name="screenshots_allow">السماح</string>
<string name="dns_over_https">DNS مع HTTPS</string>
<string name="sync_title">مزامنة بياناتك</string>
<string name="appwidget_shelf_description">مانغا من المفضلة لديك</string>
<string name="send">إرسال</string>
<string name="bookmark_add">اضافة للمحفظة</string>
<string name="screenshots_block_all">احظر دائما</string>
<string name="new_sources_text">تتوفر مصادر مانغا جديدة</string>
<string name="zoom_mode_fit_height">مناسب للارتفاع</string>
<string name="not_available">غير متاح</string>
<string name="check_new_chapters_title">التحقق من وجود فصول جديدة مع تلقي الاشعارات</string>
<string name="logged_in_as">تم تسجيل الدخول كـ %s</string>
<string name="suggestions_info">يتم تحليل جميع البيانات محليًا فقط على هذا الجهاز ولا يتم إرسالها إلى أي مكان.</string>
<string name="undo">تراجع</string>
<string name="zoom_mode_fit_center">مناسب للمركز</string>
<string name="exclude_nsfw_from_history">استبعاد مانغا الكبار من سجل التصفح</string>
<string name="download_slowdown_summary">يساعد في تجنب حظر عنوان IP الخاص بك</string>
<string name="text_delete_local_manga_batch">هل تريد حذف العناصر المحددة من الجهاز نهائيًا؟</string>
<string name="queued">في قائمة الانتظار</string>
<string name="text_shelf_holder_primary">المانغا الخاصة بك ستظهر هنا</string>
<string name="report">تبليغ</string>
<string name="download_slowdown">تبطيء التحميل</string>
<string name="sync">التزامن</string>
<string name="search_chapters">البحث عن الفصل</string>
<string name="always">دائما</string>
<string name="suggestions_excluded_genres">استبعاد الأنواع</string>
<string name="canceled">ألغيت</string>
<string name="account_already_exists">الحساب موجود بالفعل</string>
<string name="hide">أخفِ</string>
<string name="use_fingerprint">استخدم بصمة الإصبع إذا كانت متوفرة</string>
<string name="onboard_text">"حدد اللغات التي تريد قراءة المانجا بها. ويمكنك تغيير الخيار لاحقًا في الإعدادات."</string>
<string name="suggestions_updating">تحديث الاقتراحات</string>
<string name="percent_string_pattern">%1$s%%</string>
<string name="chapters_will_removed_background">ستتم إزالة الفصول في الخلفية</string>
<string name="default_mode">الوضع الافتراضي</string>
<string name="logout">تسجيل الخروج</string>
<string name="status_completed">مكتملة</string>
<string name="recent_manga">آخر التحديثات</string>
<string name="dont_check">لا تحدد</string>
<string name="zoom_mode_fit_width">مناسب للعرض</string>
<string name="reset_filter">إعادة تعيين الترتيب حسب</string>
<string name="status_dropped">متروكة</string>
<string name="nsfw">+18</string>
<string name="black_dark_theme_summary">يستهلك طاقة بطارية أقل على شاشات AMOLED</string>
<string name="notifications_enable">تمكين الإشعارات</string>
<string name="exclude_nsfw_from_suggestions">لا تقترح مانغا الكبار</string>
<string name="never">أبداً</string>
<string name="disable_battery_optimization">تعطيل \"إستهلاك أقل للبطارية\"</string>
<string name="status_planned">أنوي قرأتها</string>
<string name="pages_animation">انيميشن الصفحة</string>
<string name="genres">الأنواع</string>
<string name="other_storage">خيارات تخزين أخرى</string>
<string name="screenshots_block_nsfw">حظر على محتوى الكبار</string>
<string name="zoom_mode_keep_start">إبقاء في البداية</string>
<string name="text_local_holder_secondary">احفظه من مصادر من الانترنت أو قم باستيراد الملفات.</string>
<string name="text_local_holder_primary">قم بحفظ شيءٍ أولاً</string>
<string name="bookmarks">المحفوظات في الإشارة المرجعية</string>
<string name="empty_favourite_categories">لا توجد فئات مفضلة</string>
<string name="screenshots_policy">سياسة لقطة الشاشة</string>
<string name="done">تم</string>
</resources> </resources>

View File

@@ -543,4 +543,12 @@
<string name="restore">Restaurar</string> <string name="restore">Restaurar</string>
<string name="backup_date_">Fecha de la copia de seguridad: %s</string> <string name="backup_date_">Fecha de la copia de seguridad: %s</string>
<string name="sync_auth">Iniciar sesión en la cuenta de sincronización</string> <string name="sync_auth">Iniciar sesión en la cuenta de sincronización</string>
<string name="by_name_reverse">Nombre invertido</string>
<string name="state_upcoming">Próximamente</string>
<string name="rating_safe">Seguro</string>
<string name="rating_suggestive">Sugestivo</string>
<string name="genres_exclude">Excluir los géneros</string>
<string name="rating_adult">Adulto</string>
<string name="content_rating">Clasificación del contenido</string>
<string name="default_tab">Pestaña por defecto</string>
</resources> </resources>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="minutes_ago">
<item quantity="one">%1$d minut tagasi</item>
<item quantity="other">%1$d minutit tagasi</item>
</plurals>
<plurals name="items">
<item quantity="one">%1$d asi</item>
<item quantity="other">%1$d asja</item>
</plurals>
<plurals name="chapters">
<item quantity="one">%1$d peatükk</item>
<item quantity="other">%1$d peatükki</item>
</plurals>
<plurals name="new_chapters">
<item quantity="one">%1$d uus peatükk</item>
<item quantity="other">%1$d uut peatükki</item>
</plurals>
<plurals name="months_ago">
<item quantity="one">%1$d kuu tagasi</item>
<item quantity="other">%1$d kuud tagasi</item>
</plurals>
<plurals name="days_ago">
<item quantity="one">%1$d päev tagasi</item>
<item quantity="other">%1$d päeva tagasi</item>
</plurals>
<plurals name="hours_ago">
<item quantity="one">%1$d tund tagasi</item>
<item quantity="other">%1$d tundi tagasi</item>
</plurals>
</resources>

View File

@@ -0,0 +1,442 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="updates">Uuendused</string>
<string name="languages">Keel</string>
<string name="status_re_reading">Uuesti loen</string>
<string name="add_to_favourites">Lisa lemmikuks</string>
<string name="detect_reader_mode">Automaatne lugeja mood</string>
<string name="restore_backup_description">Impordi varem tehtud tagavara oma kasutaja andmetest</string>
<string name="manga_shelf">Riiul</string>
<string name="download_started">Allalaadimine alustatud</string>
<string name="tracking">Jälgimine</string>
<string name="close">Sulge</string>
<string name="progress">Progress</string>
<string name="text_history_holder_secondary">Leia mida lugeda «Seiklemis» osakonnas</string>
<string name="cancel_all">Tühista kõik</string>
<string name="sync_host_description">Saate kasutada ise korraldatud sünkroonimisserverit või tava sünkroonimisserverit. Ärge muutke seda, kui te ei ole kindel, mida teete.</string>
<string name="all_favourites">Kõik lemmikud</string>
<string name="email_enter_hint">Sisesta oma emaili aadress et jätkata</string>
<string name="save">Salvesta</string>
<string name="light_indicator">LED indikaator</string>
<string name="no_chapters">Pole peatükke</string>
<string name="related_manga_summary">Näita seotud mangade nimekirja. Mõnel juhul võib see olla ebatäpne või puududa</string>
<string name="theme_name_dynamic">Dünaamiline</string>
<string name="text_clear_cookies_prompt">Teid logitakse välja kõikidest allikatest</string>
<string name="clear_cookies">Tühjenda küpsisesd</string>
<string name="favourites_categories">Lemmikud kategooriad</string>
<string name="pages_cache">Lehekülgede vahemälu</string>
<string name="local_storage">Kohalik salvestamine</string>
<string name="filter">Filterid</string>
<string name="enabled_sources">Kasutatud allikad</string>
<string name="reset">Nulli</string>
<string name="disable_all">Lülita kõik välja</string>
<string name="error_occurred">Tekkis viga</string>
<string name="gestures_only">Ainult žestid</string>
<string name="order_added">Lisatud</string>
<string name="enable_logging">Lülita sisse loggig</string>
<string name="clear_thumbs_cache">Tühjenda pisipildi vahemälu</string>
<string name="taps_on_edges">Külje puudutused</string>
<string name="switch_pages">Vaheta lehekülge</string>
<string name="settings_apply_restart_required">Muudatuste rakendamiseks käivitage rakendus uuesti</string>
<string name="rotate_screen">Keera ekraani</string>
<string name="text_clear_updates_feed_prompt">Tühjenda kõik uuenduste ajalugu?</string>
<string name="source_disabled">Allikas välja lülitatud</string>
<string name="clear_cookies_summary">Võib aidata mõndade probleemidega. Kõik autoriseerimine saab invalideeritud</string>
<string name="enable_logging_summary">Salvesta mõned tegevused silumiseks. Ärge lülitage seda sisse, kui te ei ole kindel, mida teete</string>
<string name="suggestions_enable">Lülita sisse soovitused</string>
<string name="clear_feed">Tühjenda voog</string>
<string name="welcome">Tere tulemast</string>
<string name="no_description">Kirjeldus puudub</string>
<string name="about_app_translation_summary">Tõlgi see äppi</string>
<string name="text_shelf_holder_secondary">Leia mida lugeda «Seiklemis» osakonnas</string>
<string name="chapters_empty">Selles mangas ei ole peatükke</string>
<string name="remove">Eemalda</string>
<string name="vibration">Värisemine</string>
<string name="no_update_available">Uuendusi pole saadaval</string>
<string name="remove_category">Eemalda</string>
<string name="clear_all_history">Tühjenda kõik ajalugu</string>
<string name="preload_pages">Lehekülgede eellaadimine</string>
<string name="data_deletion">Andmete kustutamine</string>
<string name="favourites">Lemmikud</string>
<string name="history_shortcuts">Näita hiljutise manga lühitee</string>
<string name="downloads_wifi_only_summary">Peata allalaadimised kui vahetad mobiilsele ühendusele</string>
<string name="read_mode">Lugemis mood</string>
<string name="internal_storage">Sisemine mälu</string>
<string name="import_completed">Importitud</string>
<string name="different_languages">Erinevad keeled</string>
<string name="show_reading_indicators">Näita lugemis progressi näitaja</string>
<string name="read_later">Loe hiljem</string>
<string name="backup_saved">Tagavara salvestatud</string>
<string name="local_manga_processing">Töötlen salvestatud manga</string>
<string name="user_agent">UserAgent pealkiri</string>
<string name="cannot_find_available_storage">Puudub olemasolevat salvestamisruumi</string>
<string name="create_backup">Tee andmete tagavara</string>
<string name="detailed_list">Detailitud loetelu</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">sisse lülitatud %1$d / %2$d</string>
<string name="tap_to_try_again">Vajuta, et uuesti proovida</string>
<string name="ignore_ssl_errors">Ignoreeri SSL vigasi</string>
<string name="auth_required">Selle sisu vaatamiseks logi sisse</string>
<string name="search_manga">Otsi mangat</string>
<string name="next">Järgmine</string>
<string name="restore_backup">Taasta andmete tagavarast</string>
<string name="reader_info_bar">Näita inforiba lugejas</string>
<string name="password_length_hint">Parool peab olema 4 tähemärki või veel</string>
<string name="server_address">Sereri aadress</string>
<string name="text_feed_holder">Uued peatükid mangast mis sa loed näidatakse siin</string>
<string name="text_suggestion_holder">Hakka lugema mangat ja sa saad isikupärastatud soovitusi</string>
<string name="explore">Seikle</string>
<string name="find_similar">Leija samasuguseid</string>
<string name="show_notification_new_chapters_off">Sa ei saa teateid uuenduste kohta, aga uued peatükkid on esiletoodud nimekirjas</string>
<string name="storage_usage">Ruumi kasutatud</string>
<string name="data_restored">Taastatud</string>
<string name="favourites_category_empty">Tühi kategooria</string>
<string name="protect_application_subtitle">Sisesta parool millega startida äppi</string>
<string name="remote_sources">Manga allikad</string>
<string name="theme_name_sakura">Sakura</string>
<string name="clear">Tühjenda</string>
<string name="view_list">Vaata nimekirja</string>
<string name="by_rating">Hinnang</string>
<string name="unknown">Mitte teatud</string>
<string name="newest">Uusimad</string>
<string name="suggestions">Soovitused</string>
<string name="sort_order">Sorteerimisjärjekord</string>
<string name="clear_history">Kustuta ajalugu</string>
<string name="show_notification_new_chapters_on">Sa saad manga kohta mis sa loed teateid uuenduste kohta</string>
<string name="importing_manga">Impordi manga</string>
<string name="enabled">Sisse lülitatud</string>
<string name="pause">Paus</string>
<string name="clear_new_chapters_counters">Ja tühjenda informatsioon uute peatükkide kohta</string>
<string name="text_clear_search_history_prompt">Eemaldada kõik hiljutised otsingupäringud?</string>
<string name="nothing_here">Midagi pole siin</string>
<string name="remove_completed">Eemalda lõpetatud</string>
<string name="manga_save_location">Kaust allalaadimiseks</string>
<string name="status_reading">Loen</string>
<string name="auth_complete">Lubatud</string>
<string name="loading_">Laadimine…</string>
<string name="suggestions_notifications_summary">Vahepeal näita teateid koos soovitatud mangaga</string>
<string name="updates_feed_cleared">Tühjendatud</string>
<string name="webtoon_zoom">Webtooni zoom</string>
<string name="various_languages">Erinevad keeled</string>
<string name="list_mode">Loetelu mood</string>
<string name="removal_completed">Eemalda lõppetatud</string>
<string name="download_complete">Alla laetud</string>
<string name="theme_name_miku">Miku</string>
<string name="edit">Muuda</string>
<string name="captcha_required">CAPTCHA vajatud</string>
<string name="filter_load_error">Ei saa laadida žanrite nimekirja</string>
<string name="popular">Populaarsed</string>
<string name="manage_categories">Halda kategoorjaid</string>
<string name="update">Uuenda</string>
<string name="scrobbling_empty_hint">Lugemise edenemise jälgimiseks valige manga üksikasjade ekraanil menüü → Jälgimine.</string>
<string name="removed_from_history">Eemaldatud ajaloost</string>
<string name="crash_text">Midagi läks valesti. Palun esitage arendajatele veateade, et aidata meil seda parandada.</string>
<string name="theme">Teema</string>
<string name="detect_reader_mode_summary">Automaatselt tuvastada, kas manga on webtoon</string>
<string name="color_correction_hint">Valitud värvi seaded jäetakse meelde selle manga jaoks</string>
<string name="not_found_404">Sisu mitte saadaval või eemaldatud</string>
<string name="feed_will_update_soon">Voogu uuendused algavad varsti</string>
<string name="appwidget_recent_description">Sinu hiljuti loetud manga</string>
<string name="got_it">Saadud</string>
<string name="chapters">Peatükid</string>
<string name="appearance">Välimus</string>
<string name="bookmark_remove">Eemalda järjehoidja</string>
<string name="search_hint">Sisestage manga pealkiri, žanr või allika nimi</string>
<string name="disable_battery_optimization_summary">Aitab tausta uuenduste kontrollimisega</string>
<string name="downloads">Alla laadimised</string>
<string name="auth_not_supported_by">Sisselogimine %s ei ole toetatud</string>
<string name="app_update_available">Uus äppi versioon on olemas</string>
<string name="status_on_hold">Ootel</string>
<string name="last_2_hours">Viimased 2 tundi</string>
<string name="name">Nimi</string>
<string name="sources_reorder_tip">Vajuta ja hoija asjal, et uuesti reastada neid</string>
<string name="edit_category">Muuda kategooriat</string>
<string name="resume">Jätka</string>
<string name="server_error">Serveri poolene viga (%1$d). Palun proovi uuessti hiljem</string>
<string name="bookmark_removed">Järjehoidja eemaldatud</string>
<string name="network_unavailable_hint">Lülita sisse Wi-Fi või mobile data, et lugeda mangat online</string>
<string name="you_have_not_favourites_yet">Pole lemmikuid veel</string>
<string name="select_range">Valige vahemik</string>
<string name="tracker_warning">Mõnedel seadmetel on erinev süsteemikäitumine, mis võib katkestada taustatööd.</string>
<string name="network_error">Võrguviga</string>
<string name="new_version_s">Uus versioon: %s</string>
<string name="no_manga_sources">Pole manga allikaid</string>
<string name="light">Valge</string>
<string name="suggestions_excluded_genres_summary">Täpsusta žanrid, mida te ei soovi ettepanekutes näha</string>
<string name="text_delete_local_manga">Kustuta \"%s\" seadelt?</string>
<string name="prefetch_content">Sisu eellaeb</string>
<string name="confirm_exit">Vajuta Tagasi, et lahkuda</string>
<string name="scale_mode">skaala mood</string>
<string name="advanced">Täiustatud</string>
<string name="only_using_wifi">Ainult Wi-Fi-l</string>
<string name="automatic">Jälgne süsteemile</string>
<string name="sync_settings">Sünkroniseeri seadeid</string>
<string name="black_dark_theme">Must</string>
<string name="text_history_holder_primary">Mis sa loed näidatakse siin</string>
<string name="back">Must</string>
<string name="delete_manga">Kustuta manga</string>
<string name="create_category">Uus kategooria</string>
<string name="delete">Kustuta</string>
<string name="notification_sound">Teate hääl</string>
<string name="screenshots_allow">Luba</string>
<string name="backup_restore">Varundamine ja taastamine</string>
<string name="other_cache">Teised vahemälud</string>
<string name="dns_over_https">DNS üle HTTPS</string>
<string name="show_suspicious_content">Näita kahtlast sisu</string>
<string name="show_pages_numbers">nummerda leheküljed</string>
<string name="allow_unstable_updates_summary">Saa teateid ebastabiilsete uuenduste kohta</string>
<string name="sync_title">Sünkroniseeri oma andmed</string>
<string name="appwidget_shelf_description">Manga sinu lemmikutest</string>
<string name="comics_archive_import_description">Sa võid valida üks või veel .cbz või .zip faile, iga fail võetakse kui erinev manga.</string>
<string name="search_history_cleared">Tühjendatud</string>
<string name="_s_deleted_from_local_storage">\"%s\" eemaldatud lokaasest salvestusruumist</string>
<string name="too_many_requests_message">Liiga palju taotlusi. Proovige hiljem uuesti</string>
<string name="downloads_wifi_only">Allalaadi ainult üle Wi-Fi</string>
<string name="send">Saada</string>
<string name="bookmark_add">Lisa järjehoidja</string>
<string name="discard">Unusta</string>
<string name="saved_manga">Salvestatud manga</string>
<string name="manga_downloading_">Laadin alla…</string>
<string name="screenshots_block_all">Alati blokeeri</string>
<string name="new_sources_text">Uued manga allikad on saadaval</string>
<string name="manga_error_description_pattern">Vea detailid:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Proovi &lt;a href=%2$s&gt;avada mangat oma browseris&lt;/a&gt; et olla kindel et see manga on saadaval allikast &lt;br&gt;2. tee kindlaks et sa kasutad &lt;a href=kotatsu://about&gt;kõige uuemat versiooni Kotatsut&lt;/a&gt;&lt;br&gt;3. Kui see on saadaval, saada vea raport arendajatele.</string>
<string name="open_in_browser">Ava veebibrauser</string>
<string name="about_app_translation">Tõlge</string>
<string name="zoom_mode_fit_height">Sobita kõrgusele</string>
<string name="notifications">Teated</string>
<string name="not_available">Mitte saadaval</string>
<string name="check_new_chapters_title">Otsi uusi peatükke ja tevita sellest</string>
<string name="history_shortcuts_summary">Tee hiljutine manga saadaval hoides pikkalt applikatsiooni ikoonil</string>
<string name="automatic_scroll">Automaatiline scroll</string>
<string name="reverse">Tagurpidi</string>
<string name="track_sources">Otsi uusi uuendusi</string>
<string name="paused">Pausis</string>
<string name="clear_search_history">Tühjenda otsingute ajalugu</string>
<string name="wrong_password">Vale parool</string>
<string name="group">Grupp</string>
<string name="_import">Impordi</string>
<string name="color_correction">Värvi korektsioon</string>
<string name="just_now">Just nüüd</string>
<string name="text_file_not_supported">Vali kas ZIP või CBZ fail.</string>
<string name="download">Lae alla</string>
<string name="chapter_is_missing">See peatükk puudub</string>
<string name="categories_delete_confirm">Kas sa oled kindel, et tahad kustutada valitud lemmikud kategooriad?
\nKõik manga nende sees kaob ja seda tagasi ei saa.</string>
<string name="logged_in_as">Logitud sisse kui %s</string>
<string name="suggestions_info">Kõik andmed analüüsitakse ainult lokaalselt selles seadmes ja neid ei saadeta kunagi kuhugi.</string>
<string name="reader_info_pattern">Pt. %1$d/%2$d Lk. %3$d/%4$d</string>
<string name="contrast">Kontrast</string>
<string name="history_cleared">Ajalugu tühjendatud</string>
<string name="size_s">Suurus: %s</string>
<string name="reader_slider">Näita lehekülje vahetamise liugurit</string>
<string name="no_manga_sources_text">Lülita sisse manga allikaid, et lugeda mangat online</string>
<string name="about">Kohta</string>
<string name="undo">Tühista</string>
<string name="zoom_mode_fit_center">Sobita keskele</string>
<string name="options">Valikud</string>
<string name="services">Teenused</string>
<string name="exclude_nsfw_from_history">Jäta NSFW manga ajaloost välja</string>
<string name="clear_pages_cache">Tühjenda lehekülje vahemälu</string>
<string name="download_slowdown_summary">Aitab vältida teie IP-aadressi blokeerimist</string>
<string name="check_for_new_chapters">Otsi uusi peatükke</string>
<string name="read">Loe</string>
<string name="text_delete_local_manga_batch">Kustuta valitud asjad seadmelt?</string>
<string name="queued">Järjekorras</string>
<string name="text_clear_history_prompt">Tühjenda kõik lugemis ajalugu?</string>
<string name="text_shelf_holder_primary">Sinu mangat näidatakse siin</string>
<string name="report">Raporteeri</string>
<string name="captcha_solve">Lahenda</string>
<string name="download_slowdown">Aeglusta alla laadimised</string>
<string name="bookmark_added">Järjehoidja lisatud</string>
<string name="data_restored_with_errors">Andmed on taastatud, aga on vigasi</string>
<string name="details">Detailid</string>
<string name="sync">Sünkronisatsioon</string>
<string name="new_chapters">Uued peatükid</string>
<string name="search_chapters">Leia peatükk</string>
<string name="exit_confirmation">Lahkumise kinnitus</string>
<string name="comics_archive">Koomikute arhiiv</string>
<string name="more">Rohkem</string>
<string name="theme_name_asuka">Asuka</string>
<string name="history_is_empty">Pole mingit ajalugu veel</string>
<string name="always">Alati</string>
<string name="import_will_start_soon">Import algab varsti</string>
<string name="try_again">Proovi uuesti</string>
<string name="compact">Kompaktne</string>
<string name="protect_application">Kaitse seda äppi</string>
<string name="folder_with_images_import_description">Sa võid valida kausta arhiivi või piltidega. Iga arhiiv (või alamkaust) võetakse kui erinev peatükk.</string>
<string name="reorder">ümberjärjestamine</string>
<string name="share_s">Jaga %s</string>
<string name="suggestions_excluded_genres">Välista žanrid</string>
<string name="chapter_d_of_d">Peatükid %1$d %2$d-st</string>
<string name="canceled">Peatatud</string>
<string name="feed">Voog</string>
<string name="account_already_exists">Kasutaja juba eksisteerib</string>
<string name="dark">Must</string>
<string name="hide">Peida</string>
<string name="passwords_mismatch">Ebavõrdsed paroolid</string>
<string name="by_name">Nimi</string>
<string name="yesterday">Eile</string>
<string name="check_for_updates">Otsi uuendusi</string>
<string name="exclude_nsfw_from_history_summary">Manga märkitud kui NSFW kungai ei lisata sinu ajalukku ja progressi ei salvestata</string>
<string name="mark_as_current">Margi kui präegune</string>
<string name="protect_application_summary">Kusi parooli avades Kotatsut</string>
<string name="right_to_left">Paremalt-vasakule</string>
<string name="show_reading_indicators_summary">Näita mitu protsenti on loetud ajaloos ja lemmikutes</string>
<string name="random">Suvakas</string>
<string name="mirror_switching">Vali peegeldus automaatselt</string>
<string name="use_fingerprint">Kasuta näppujälge, kui see on olemas</string>
<string name="standard">Standard</string>
<string name="history">Ajalugu</string>
<string name="nothing_found">Mitte midagi leitud</string>
<string name="onboard_text">Väli keeled milles sa tahad mangat lugeda. Seda võib hiljem seadetes muuta.</string>
<string name="show_in_grid_view">Näita ruudustiku vaates</string>
<string name="reader_mode_hint">Valitud konfiguratsioon jäetakse meelde selle manga jaoks</string>
<string name="default_s">Default: %s</string>
<string name="volume_buttons">Helitugevusnupud</string>
<string name="confirm">Kinnita</string>
<string name="suggestions_updating">Soovitused uuendavad</string>
<string name="add_new_category">Uus kategooria</string>
<string name="clear_updates_feed">Tühjenda uuenduste voog</string>
<string name="enable">Lülita sisse</string>
<string name="percent_string_pattern">%1$s%%</string>
<string name="chapters_will_removed_background">Peatükid eemaldatakse taustal</string>
<string name="import_completed_hint">Ruumi säästmiseks saad originaalfaili salvestusruumist kustutada</string>
<string name="theme_name_rikka">Rikka</string>
<string name="share_image">Jaga pilt</string>
<string name="disabled">Välja lülitatud</string>
<string name="long_ago">Kaua aega tagasi</string>
<string name="reader_control_ltr_summary">Vajutades paremal äärele või paremale nuppule alati vahetub järgmisele leheküljele</string>
<string name="list">Loetelu</string>
<string name="notifications_settings">Teate seaded</string>
<string name="language">Keel</string>
<string name="domain">Domeen</string>
<string name="save_manga">Salvesta</string>
<string name="incognito_mode">Inkognito mood</string>
<string name="no_bookmarks_summary">Sa võid teha järjehoidjaid mangat lugedes</string>
<string name="large_manga_save_confirm">Sellel mangal on %s. Salvesta kõik?</string>
<string name="search">Otsi</string>
<string name="default_mode">Tava mood</string>
<string name="reader_settings">Lugeja seaded</string>
<string name="theme_name_mamimi">Mamimi</string>
<string name="text_search_holder_secondary">Proovi otsingut teiste sõnadega.</string>
<string name="error">Viga</string>
<string name="grid_size">Ruudustiku suurus</string>
<string name="manage">Halda</string>
<string name="logout">Logi välja</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string>
<string name="status_completed">Lõpetatud</string>
<string name="_continue">Jätka</string>
<string name="grid">Ruudustik</string>
<string name="recent_manga">Hiljutised</string>
<string name="operation_not_supported">See operatsioon ei ole toetatud</string>
<string name="reader_control_ltr">Ergonoomiline lügeja kontroll</string>
<string name="dont_check">Ära kontrolli</string>
<string name="save_page">Salvesta lehekülg</string>
<string name="zoom_mode_fit_width">Sobita laijusele</string>
<string name="read_more">Loe veel</string>
<string name="reset_filter">Nulli filter</string>
<string name="search_on_s">Otsi %s-st</string>
<string name="status_dropped">Kukkutatud</string>
<string name="nsfw">18+</string>
<string name="disable_nsfw">Lülita välja NSFW</string>
<string name="processing_">Töötlen…</string>
<string name="black_dark_theme_summary">Kasuta vähem elektrit AMOLED ekraanidel</string>
<string name="available">Saadaval</string>
<string name="notifications_enable">Lülita sisse notifikatsioonid</string>
<string name="network_unavailable">Võrk ei ole saadaval</string>
<string name="search_results">Otsingutulemused</string>
<string name="empty">Tühi</string>
<string name="webtoon">Webtoon</string>
<string name="exclude_nsfw_from_suggestions">Ära soovita NSFW mangat</string>
<string name="file_not_found">Fail mitte leitud</string>
<string name="never">Mitte kungai</string>
<string name="text_unsaved_changes_prompt">Salvesta või unusta mitte salvestatud muutused?</string>
<string name="settings">Seaded</string>
<string name="folder_with_images">Kaustad piltitega</string>
<string name="pages">Leheküljed</string>
<string name="app_version">Versioon %s</string>
<string name="cookies_cleared">Kõik küpsised on eemaldatud</string>
<string name="disable_battery_optimization">Lülita välja aku optimeerimine</string>
<string name="status_planned">Planeeritud</string>
<string name="pages_animation">Lehekülje animatsioon</string>
<string name="state_finished">Lõppetatud</string>
<string name="state_ongoing">Käimasolev</string>
<string name="suggestions_summary">Soovita manga vastavalt oma eelistustele</string>
<string name="show">Näita</string>
<string name="genres">Žanrid</string>
<string name="show_on_shelf">Näita riiulil</string>
<string name="allow_unstable_updates">Luba ebastabiilseid uuendusi</string>
<string name="sync_auth_hint">Sa võid sisse logida olemasolevasse kasutajasse või teha uue</string>
<string name="create_shortcut">Tee otsetee…</string>
<string name="theme_name_kanade">Kanade</string>
<string name="other_storage">Teine salvestamisruum</string>
<string name="updated">Uuendatud</string>
<string name="color_theme">Värvi skeema</string>
<string name="brightness">Helendus</string>
<string name="memory_usage_pattern">%s - %s</string>
<string name="preparing_">Valmistan…</string>
<string name="share">Jaga</string>
<string name="computing_">Arvutan…</string>
<string name="add">Lisa</string>
<string name="exit_confirmation_summary">Vajuta Tagasi kaks korda, et lahkuda äppist</string>
<string name="bookmarks_removed">Järjehoidjad eemaldatud</string>
<string name="screenshots_block_nsfw">Blokeeri kui NSFW</string>
<string name="enter_password">Sisesta parool</string>
<string name="repeat_password">Korda parooli</string>
<string name="data_restored_success">Kõik andmed on taastatud</string>
<string name="theme_name_mion">Mion</string>
<string name="mirror_switching_summary">Automaatselt vaheta domeene manga allika jaoks veaga kui peegeldused on saadaval</string>
<string name="no_bookmarks_yet">Pole järjehoidjaid veel</string>
<string name="no_thanks">Tänan ei</string>
<string name="backup_information">Sa saad teha tagavara enda ajaloost, lemmikutest ja siis taastada need</string>
<string name="available_sources">Kättesaadavad allikad</string>
<string name="share_logs">Jaga looge</string>
<string name="zoom_mode_keep_start">Hoija stardis</string>
<string name="external_storage">Väline mälu</string>
<string name="text_local_holder_secondary">Salvesta see interneti allikast või impordidtud failist.</string>
<string name="speed">Kiirus</string>
<string name="silent">Vaikne</string>
<string name="text_local_holder_primary">Salvesta midagi esimesena</string>
<string name="suggestion_manga">Soovitused: %s</string>
<string name="removed_from_favourites">Eemaldatud lemmikutest</string>
<string name="bookmarks">Järjehoidja</string>
<string name="show_all">Näita kõik</string>
<string name="page_saved">Salvestatud</string>
<string name="today">Täna</string>
<string name="empty_favourite_categories">Pole lemmikkategooriaid</string>
<string name="invalid_domain_message">Kehtetu domeen</string>
<string name="system_default">Tava</string>
<string name="text_empty_holder_primary">On üsna tühi siin…</string>
<string name="screenshots_policy">Ekraanipiltide poliitika</string>
<string name="done">Tehtud</string>
<string name="error_no_space_left">Ruumi pole ülejäänud seadmel</string>
<string name="sign_in">Logi sisse</string>
<string name="remove_completed_downloads_confirm">Sinu allalaadimise ajalugu kustutatakse igaveseks</string>
<string name="password">Parool</string>
<string name="data_and_privacy">Andmed ja privaatsus</string>
<string name="invalid_value_message">Kehtetu väärtus</string>
<string name="downloads_cancelled">Allalaadimised on peatatud</string>
<string name="web_view_unavailable">WebView ei ole saadaval: kontrollige kas WebView pakkuja on installitud</string>
<string name="port">Port</string>
<string name="type">Tüüp</string>
<string name="images_proxy_title">Piltide optimeerimise puhverserver</string>
<string name="username">Kasutaja nimi</string>
<string name="authorization_optional">Autoriseerimine (valikuline)</string>
<string name="translations">Tõlked</string>
<string name="downloads_paused">Allalaadimised on pausil</string>
<string name="cancel_all_downloads_confirm">Kõik aktiivsed allalaadimised peatatakse ja poolenisti allalaetud sisu kaotatakse</string>
<string name="text_downloads_list_holder">Sul pole midagi allalaetud</string>
<string name="invalid_port_number">Vigane pordi number</string>
<string name="webtoon_zoom_summary">Luba sisse suumimis liigutusi webtooni moodis</string>
<string name="network">Võrk</string>
<string name="downloaded">Allalaetud</string>
<string name="suggestions_enable_prompt">Kas te soovite saada personaliseerituid manga soovitusi?</string>
<string name="address">Aadress</string>
<string name="downloads_removed">Allalaadimised on eemaldatud</string>
<string name="restore_summary">Taasta varem loodud varukoopia</string>
<string name="clear_network_cache">Tühjendage võrgu vahemälu</string>
<string name="images_procy_description">Kasutage wsrv.nl teenust liikluskasutuse vähendamiseks ja võimalusel piltide laadimise kiirendamiseks</string>
<string name="manga_branch_title_template">%1$s (%2$s)</string>
<string name="downloads_resumed">Allalaadimised on jätkanud</string>
<string name="invert_colors">Värvide ümberpööramine</string>
<string name="proxy">Puhverserver</string>
</resources>

View File

@@ -543,4 +543,12 @@
<string name="disable_battery_optimization_summary_downloads">Baka makatulong sa pag-simula ng download kung mayroong isyu</string> <string name="disable_battery_optimization_summary_downloads">Baka makatulong sa pag-simula ng download kung mayroong isyu</string>
<string name="backup_date_">Petsa ng pag-backup: %s</string> <string name="backup_date_">Petsa ng pag-backup: %s</string>
<string name="sync_auth">Mag-login para i-sync ang account</string> <string name="sync_auth">Mag-login para i-sync ang account</string>
<string name="by_name_reverse">Binaligtad ang pangalan</string>
<string name="state_upcoming">Paparating</string>
<string name="rating_safe">Ligtas</string>
<string name="rating_suggestive">Mayroong pahiwatig</string>
<string name="genres_exclude">Ibukod na dyanra</string>
<string name="rating_adult">Nasa gulang</string>
<string name="content_rating">Content rating</string>
<string name="default_tab">Default na tab</string>
</resources> </resources>

View File

@@ -505,4 +505,38 @@
<string name="last_successful_backup">Cadangan sukses terakhir: %s</string> <string name="last_successful_backup">Cadangan sukses terakhir: %s</string>
<string name="backups_output_directory">Direktori keluaran cadangan</string> <string name="backups_output_directory">Direktori keluaran cadangan</string>
<string name="suggest_new_sources_summary">Prompt untuk mengaktifkan sumber baru yang ditambahkan setelah memperbarui aplikasi</string> <string name="suggest_new_sources_summary">Prompt untuk mengaktifkan sumber baru yang ditambahkan setelah memperbarui aplikasi</string>
<string name="sources_catalog">Sumber katalog</string>
<string name="rating_safe">Aman</string>
<string name="content_type_manga">Komik</string>
<string name="content_type_hentai">Hentai</string>
<string name="error_filter_states_genre_not_supported">Filter berdasarkan genre dan negara tidak didukung untuk sumber ini</string>
<string name="error_filter_locale_genre_not_supported">Filter bedasarkan genre dan lokal tidak didukung oleh sumber ini</string>
<string name="content_type_comics">Komik</string>
<string name="catalog">Katalog</string>
<string name="welcome_text">Silahkan pilih sumber konten komik mana yang akan diaktifkan. konfigurasi ini dapat dilakukan nanti di pengaturan</string>
<string name="genres_exclude">Kecualikan genre</string>
<string name="reader_optimize">Kurangi konsumsu memori (beta)</string>
<string name="apply">Terapkan</string>
<string name="restore">Kembalikan</string>
<string name="manage_sources">Kelola sumber</string>
<string name="no_manga_sources_found">Tidak ada sumber komik yang tersedia berdasarkan query yang kamu masukan</string>
<string name="genres_search_hint">Ketik nama genre</string>
<string name="globally">Secara global</string>
<string name="rating_adult">Dewasa</string>
<string name="error_multiple_genres_not_supported">Filter berdasarkan banyak genre sekaligus tidak di dukung berdasarkan sumber manga ini</string>
<string name="this_manga">Manga ini</string>
<string name="lock_screen_rotation">Kunci rotasi layar</string>
<string name="skip">Lewati</string>
<string name="error_search_not_supported">Pencarian tidak didukung untuk sumber komik ini</string>
<string name="state_upcoming">Mendatang</string>
<string name="color_correction_apply_text">Pengaturan ini dapat diterapkan secara menyeluruh atau hanya pada sumber manga saat ini. Jika diterapkan secara menyeluruh, pengaturan pada suatu sumber manga tidak akan di ubah / ditimpa.</string>
<string name="source_enabled">Sumber yang diaktifkan</string>
<string name="disable_nsfw_summary">Matikan sumber NSFW and sembunyikan komik dewasa dari daftar jika memungkinkan</string>
<string name="content_rating">Peringkat konten</string>
<string name="backup_date_">Tanggal dicadangkan %s</string>
<string name="available_d">Tersedia:%1$d</string>
<string name="state">Negara</string>
<string name="state_paused">Di jeda</string>
<string name="content_type_other">Lainnya</string>
<string name="sync_auth">Masuk untuk sinkronisasi akun</string>
</resources> </resources>

View File

@@ -268,7 +268,7 @@
<string name="show_reading_indicators">Mostrare gli indicatori di progresso della lettura</string> <string name="show_reading_indicators">Mostrare gli indicatori di progresso della lettura</string>
<string name="data_deletion">Eliminazione dei dati</string> <string name="data_deletion">Eliminazione dei dati</string>
<string name="show_reading_indicators_summary">Mostra la percentuale di lettura nella cronologia e nei preferiti</string> <string name="show_reading_indicators_summary">Mostra la percentuale di lettura nella cronologia e nei preferiti</string>
<string name="exclude_nsfw_from_history_summary">I manga contrassegnati come per adulti non verranno mai aggiunti alla cronologia e i vostri progressi non verranno salvati</string> <string name="exclude_nsfw_from_history_summary">I manga contrassegnati come per adulti non verranno mai aggiunti alla cronologia e i progressi non saranno salvati</string>
<string name="clear_cookies_summary">Può aiutare in caso di problemi. Tutte le autorizzazioni saranno invalidate</string> <string name="clear_cookies_summary">Può aiutare in caso di problemi. Tutte le autorizzazioni saranno invalidate</string>
<string name="show_all">Mostra tutto</string> <string name="show_all">Mostra tutto</string>
<string name="logout">Esci</string> <string name="logout">Esci</string>
@@ -345,12 +345,12 @@
\n Tutti i manga in esso contenuti andranno persi e questo non può essere annullato.</string> \n Tutti i manga in esso contenuti andranno persi e questo non può essere annullato.</string>
<string name="history_shortcuts_summary">Rendere disponibili i manga recenti premendo a lungo sull\'icona dell\'applicazione</string> <string name="history_shortcuts_summary">Rendere disponibili i manga recenti premendo a lungo sull\'icona dell\'applicazione</string>
<string name="reader_info_bar">Mostra la barra delle informazioni nel lettore</string> <string name="reader_info_bar">Mostra la barra delle informazioni nel lettore</string>
<string name="manga_error_description_pattern">Dettagli dell\'errore:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;1. Prova ad &lt;a href=%2$s&gt;aprire il manga in un browser web&lt;/a&gt; per assicurarsi che sia disponibile sulla sua fonte&lt;br&gt;2. Se è disponibile, inviare una segnalazione di errore agli sviluppatori.</string> <string name="manga_error_description_pattern">Dettagli dell\'errore:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Prova ad &lt;a href=%2$s&gt;aprire il manga in un browser web&lt;/a&gt; per assicurarsi che sia disponibile sulla sua fonte&lt;br&gt;2. Controllare di stare usando la &lt;a href=kotatsu://about&gt;versione più recente di Kotatsu&lt;/a&gt;&lt;br&gt;3. Se è disponibile, inviare una segnalazione di errore agli sviluppatori.</string>
<string name="text_unsaved_changes_prompt">Salvare o eliminare le modifiche non salvate\?</string> <string name="text_unsaved_changes_prompt">Salvare o eliminare le modifiche non salvate\?</string>
<string name="discard">Abbandona</string> <string name="discard">Abbandona</string>
<string name="language">Lingua</string> <string name="language">Lingua</string>
<string name="share_logs">Condividi i registri</string> <string name="share_logs">Condividi i registri</string>
<string name="enable_logging_summary">Registra alcune azioni a scopo di debug</string> <string name="enable_logging_summary">Registra alcune azioni a scopo di debug. Non attivare se non si è sicuri di cosa si stia facendo</string>
<string name="enable_logging">Abilita la registrazione</string> <string name="enable_logging">Abilita la registrazione</string>
<string name="show_suspicious_content">Mostra il contenuto sospetto</string> <string name="show_suspicious_content">Mostra il contenuto sospetto</string>
<string name="theme_name_dynamic">Dinamico</string> <string name="theme_name_dynamic">Dinamico</string>
@@ -366,7 +366,181 @@
<string name="theme_name_rikka">Rikka</string> <string name="theme_name_rikka">Rikka</string>
<string name="theme_name_miku">Miku</string> <string name="theme_name_miku">Miku</string>
<string name="allow_unstable_updates">Permetti aggiornamenti instabili</string> <string name="allow_unstable_updates">Permetti aggiornamenti instabili</string>
<string name="allow_unstable_updates_summary">Proponi aggiornamenti alle versioni beta dell\'app</string> <string name="allow_unstable_updates_summary">Ricevi notifiche riguardo versioni instabili</string>
<string name="download_started">Scaricamento iniziato</string> <string name="download_started">Scaricamento iniziato</string>
<string name="theme_name_sakura">Sakura</string> <string name="theme_name_sakura">Sakura</string>
<string name="languages">Lingue</string>
<string name="zoom_in">Ingrandire</string>
<string name="captcha_required_summary">%s richiede il completamento di un captcha per funzionare correttamente</string>
<string name="sources_catalog">Catalogo fonti</string>
<string name="download_option_all_unread">Tutti i capitoli non letti</string>
<string name="frequency_every_day">Ogni giorno</string>
<string name="categories">Categorie</string>
<string name="progress">Progresso</string>
<string name="cancel_all">Annulla tutti</string>
<string name="sync_host_description">Puoi usare un server di sincronizzazione self-hosted oppure uno di default. Non cambiare se non si è sicuri di cosa si stia facendo.</string>
<string name="error_corrupted_file">Dati non validi in ritorno o file corrotto</string>
<string name="pick_custom_directory">Seleziona cartella personalizzata</string>
<string name="list_options">Mostra lista opzioni</string>
<string name="related_manga_summary">Mostra una lista di manga correlati. In alcuni casi potrebbe essere imprecisa o mancante</string>
<string name="remove_completed_downloads_confirm">La tua cronologia dei download sarà permanentemente cancellata</string>
<string name="reader_zoom_buttons_summary">Se mostrare o no i tasti di controllo dello zoom in basso a destra</string>
<string name="content_type_manga">Manga</string>
<string name="tracker_wifi_only_summary">Non controllare nuovi capitoli mentre è in uso una connessione a consumo</string>
<string name="error_multiple_states_not_supported">Questa fonte di manga non supporta il filtraggio per multipli stati</string>
<string name="order_added">Aggiunto</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="on_device">Su dispositivo</string>
<string name="password">Password</string>
<string name="download_option_whole_manga">L\'intero manga</string>
<string name="settings_apply_restart_required">Riavviare l\'applicazione per applicare queste modifiche</string>
<string name="backup_frequency">Frequenza creazione backup</string>
<string name="data_and_privacy">Dati e privacy</string>
<string name="content_type_hentai">Hentai</string>
<string name="clear_source_cookies_summary">Pulisci cookies solo per dominio specifico. Nella maggior parte dei casi invaliderà l\'autorizzazione</string>
<string name="downloads_wifi_only_summary">Smetti di scaricare quando si passa a rete mobile</string>
<string name="suggest_new_sources">Suggerisci nuove fonti dopo aggiornamento dell\'app</string>
<string name="user_agent">Intestazione UserAgent</string>
<string name="error_filter_states_genre_not_supported">Questa fonte non supporta il filtraggio per sia genere che stato</string>
<string name="ignore_ssl_errors">Ignora errori SSL</string>
<string name="error_filter_locale_genre_not_supported">Questa fonte non supporta il filtraggio per sia genere che localizzazione</string>
<string name="periodic_backups_enable">Abilita backup periodici</string>
<string name="server_address">Indirizzo server</string>
<string name="content_type_comics">Fumetti</string>
<string name="moved_to_top">Spostato in cima</string>
<string name="find_similar">Trova simili</string>
<string name="data_not_restored_text">Assicurati di aver selezionato il file di backup corretto</string>
<string name="catalog">Catalogo</string>
<string name="view_list">Vedi lista</string>
<string name="welcome_text">Selezionare le fonti di contenuti da abilitare. Si può configurare anche successivamente nelle impostazioni</string>
<string name="unknown">Sconosciuto</string>
<string name="in_progress">In progresso</string>
<string name="download_option_manual_selection">Selezione manuale capitoli</string>
<string name="enhanced_colors_summary">Riduce il banding, ma può impattare la performance</string>
<string name="pause">Pausa</string>
<string name="remove_completed">Rimuovi completati</string>
<string name="items_limit_exceeded">Impossibile aggiungere altri oggetti</string>
<string name="frequency_every_2_days">Ogni 2 giorni</string>
<string name="suggestions_notifications_summary">A volte mostra le notifiche sui manga suggeriti</string>
<string name="invalid_value_message">Valore non valido</string>
<string name="downloads_cancelled">I download sono stati annullati</string>
<string name="reader_optimize">Riduci consumo di memoria (beta)</string>
<string name="apply">Applica</string>
<string name="restore">Ripristina</string>
<string name="data_not_restored">Dati non ripristinati</string>
<string name="manage_sources">Gestisci fonti</string>
<string name="directories">Cartelle</string>
<string name="local_manga_directories">Cartelle manga locali</string>
<string name="manage_categories">Gestisci categorie</string>
<string name="no_manga_sources_found">Nessuna fonte manga trovata in base alla tua query</string>
<string name="color_light">Chiaro</string>
<string name="web_view_unavailable">WebView non disponibile: controlla se è installato un provider di WebView</string>
<string name="genres_search_hint">Inizia a scrivere il nome del genere</string>
<string name="port">Porta</string>
<string name="type">Tipo</string>
<string name="search_hint">Inserisci titolo manga, genere o nome fonte</string>
<string name="frequency_once_per_week">Una volta a settimana</string>
<string name="description">Descrizione</string>
<string name="periodic_backups">Backup periodici</string>
<string name="reader_zoom_buttons">Mostra tasti zoom</string>
<string name="sources_reorder_tip">Tocca e tieni premuto su un elemento per riordinarlo</string>
<string name="globally">Globalmente</string>
<string name="resume">Riprendi</string>
<string name="images_proxy_title">Proxy ottimizzazione immagini</string>
<string name="username">Nome utente</string>
<string name="frequency_twice_per_month">Due volte al mese</string>
<string name="main_screen_sections">Sezioni principali schermo</string>
<string name="advanced">Avanzate</string>
<string name="downloads_settings_info">Se stai avendo problemi di blocco dal server, puoi abilitare il rallentamento dei download per ogni fonte di manga nelle impostazioni delle fonti</string>
<string name="sync_settings">Impostazioni di sincronizzazione</string>
<string name="online_variant">Variante online</string>
<string name="disable_battery_optimization_summary_downloads">Potrebbe aiutare a far iniziare il download in caso di problemi</string>
<string name="error_multiple_genres_not_supported">Questa fonte di manga non supporta il filtraggio per multipli generi</string>
<string name="download_option_all_unread_b">Tutti i capitoli non letti (%s)</string>
<string name="authorization_optional">Autorizzazione (opzionale)</string>
<string name="color_dark">Scuro</string>
<string name="this_manga">Questo manga</string>
<string name="reader_info_bar_summary">Mostra l\'orario attuale e il progresso di lettura in cima allo schermo</string>
<string name="translations">Traduzioni</string>
<string name="downloads_paused">I download sono stati messi in pausa</string>
<string name="too_many_requests_message">Troppe richieste. Riprova più tardi</string>
<string name="downloads_wifi_only">Scarica solo via Wi-Fi</string>
<string name="lock_screen_rotation">Blocca rotazione schermo</string>
<string name="cancel_all_downloads_confirm">Tutti i download attivi saranno annullati, i dati scaricati parzialmente saranno persi</string>
<string name="by_relevance">Rilevanza</string>
<string name="related_manga">Manga correlati</string>
<string name="state_abandoned">Abbandonato</string>
<string name="download_option_first_n_chapters">Primo %s</string>
<string name="keep_screen_on">Tieni schermo acceso</string>
<string name="paused">In pausa</string>
<string name="text_downloads_list_holder">Non hai alcun download</string>
<string name="skip">Salta</string>
<string name="error_search_not_supported">Questa fonte di manga non supporta la ricerca</string>
<string name="invalid_port_number">Numero di porta non valido</string>
<string name="suggestions_wifi_only_summary">Non aggiornare suggerimenti mentre è in uso una connessione a consumo</string>
<string name="webtoon_zoom_summary">Consenti gesto di zoom in modalità webtoon</string>
<string name="frequency_once_per_month">Una volta al mese</string>
<string name="network">Rete</string>
<string name="downloaded">Scaricato</string>
<string name="suggestions_enable_prompt">Vuoi ricevere suggerimenti personalizzati di manga?</string>
<string name="manual">Manuale</string>
<string name="custom_directory">Cartella personalizzata</string>
<string name="more">Di più</string>
<string name="reader_optimize_summary">Riduci la qualità delle pagine fuori schermo per usare meno memoria</string>
<string name="address">Indirizzo</string>
<string name="color_correction_apply_text">Queste impostazioni possono essere applicate globalmente o per solo questo manga. Le applicazioni globali non sovrascrivono quelle individuali.</string>
<string name="source_enabled">Fonte abilitata</string>
<string name="enhanced_colors">Modalità colori 32-bit</string>
<string name="folder_with_images_import_description">Puoi selezionare una cartella contenente archivi o immagini. Ogni archivio (o sottocartella) sarà riconosciuto come singolo capitolo.</string>
<string name="default_section">Sezione default</string>
<string name="background">Sfondo</string>
<string name="disable_nsfw_summary">Disabilita fonti per adulti e nascondi manga per adulti dalla lista se possibile</string>
<string name="speed_value">x%.1f</string>
<string name="downloads_removed">I download sono stati rimossi</string>
<string name="pages_animation_summary">Animazioni cambio pagina</string>
<string name="no_access_to_file">Non hai accesso a questo file o cartella</string>
<string name="mirror_switching">Scegli mirror automaticamente</string>
<string name="restore_summary">Ripristina backup creato in precedenza</string>
<string name="show_pages_numbers_summary">Mostra numeri di pagina nell\'angolo inferiore</string>
<string name="zoom_out">Rimpicciolire</string>
<string name="keep_screen_on_summary">Non spegnere lo schermo durante la lettura di manga</string>
<string name="download_option_next_unread_n_chapters">Prossimo %s non letto</string>
<string name="clear_network_cache">Pulisci cache di rete</string>
<string name="voice_search">Ricerca vocale</string>
<string name="enable">Abilita</string>
<string name="backup_date_">Data del backup: %s</string>
<string name="images_procy_description">Usa il servizio wsrv.nl per ridurre i consumi di rete e velocizzare il caricamento delle immagini, se possibile</string>
<string name="no_manga_sources_catalog_text">Non sono disponibili fonti in questa sezione, o potrebbero essere state aggiunte tutte.
\nRimani sintonizzato</string>
<string name="available_d">Disponibile: %1$d</string>
<string name="manga_branch_title_template">%1$s (%2$s)</string>
<string name="state">Stato</string>
<string name="manga_list">Lista manga</string>
<string name="grayscale">Bianco e nero</string>
<string name="disable_nsfw">Disabilita contenuti per adulti</string>
<string name="last_successful_backup">Ultimo backup completato: %s</string>
<string name="color_white">Bianco</string>
<string name="downloads_resumed">I download sono stati ripresi</string>
<string name="details_button_tip">Tocca e tieni premuto il tasto Leggi per vedere più opzioni</string>
<string name="state_paused">In pausa</string>
<string name="to_top">Va in cima</string>
<string name="show">Mostra</string>
<string name="sync_auth_hint">Puoi accedere ad un account esistente o crearne uno nuovo</string>
<string name="backups_output_directory">Cartella salvataggio backup</string>
<string name="invert_colors">Inverti colori</string>
<string name="mirror_switching_summary">Cambiare dominio di fonte del manga automaticamente in caso di errore se sono disponibili mirror</string>
<string name="no_thanks">No grazie</string>
<string name="suggest_new_sources_summary">Chiedi se aggiungere nuove fonti dopo aggiornamento dell\'applicazione</string>
<string name="speed">Velocità</string>
<string name="download_option_all_chapters">Tutti i capitoli con traduzioni %s</string>
<string name="content_type_other">Altro</string>
<string name="suggestion_manga">Consigliato: %s</string>
<string name="color_black">Nero</string>
<string name="this_month">Questo mese</string>
<string name="sync_auth">Accedi per sincronizzare l\'account</string>
<string name="proxy">Proxy</string>
<string name="restore_backup_description">Importa un backup di dati utente creato precedentemente</string>
<string name="got_it">Ho capito</string>
<string name="comics_archive_import_description">Puoi selezionare uno o più file .cbz o .zip, ogni file sarà riconosciuto come un manga a parte.</string>
<string name="show_on_shelf">Mostra sullo Scaffale</string>
</resources> </resources>

View File

@@ -11,9 +11,9 @@
<item quantity="other">%1$d capítulos</item> <item quantity="other">%1$d capítulos</item>
</plurals> </plurals>
<plurals name="new_chapters"> <plurals name="new_chapters">
<item quantity="one">%1$d novo capítulo</item> <item quantity="one">%1$d capítulo novo</item>
<item quantity="many">%1$d novos capítulos</item> <item quantity="many">%1$d capítulos novos</item>
<item quantity="other">%1$d novos capítulos</item> <item quantity="other">%1$d capítulos novos</item>
</plurals> </plurals>
<plurals name="minutes_ago"> <plurals name="minutes_ago">
<item quantity="one">%1$d minuto atrás</item> <item quantity="one">%1$d minuto atrás</item>

Some files were not shown because too many files have changed in this diff Show More