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/workspace.xml
/.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/kotlinc.xml

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 608
versionName = '6.5.2'
versionCode = 611
versionName = '6.6'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,12 +82,12 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:4a0e7221b0') {
implementation('com.github.KotatsuApp:kotatsu-parsers:e03d0efe71') {
exclude group: 'org.json', module: 'json'
}
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 '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.parsers.model.Manga
import java.util.Date
import java.time.Instant
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
createdAt = Instant.ofEpochMilli(createdAt),
percent = percent,
)
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
createdAt = createdAt.toEpochMilli(),
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.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
import java.time.Instant
data class Bookmark(
val manga: Manga,
@@ -13,7 +13,7 @@ data class Bookmark(
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
val createdAt: Instant,
val percent: Float,
) : ListModel {

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier(
private val context: Context,
@@ -58,6 +59,10 @@ class CaptchaNotifier(
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) {
super.onError(request, result)
val e = result.throwable

View File

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

View File

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

View File

@@ -31,6 +31,16 @@ abstract class TagsDao {
)
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(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import java.util.Date
import java.time.Instant
import java.time.temporal.ChronoUnit
class TooManyRequestExceptions(
val url: String,
val retryAt: Date?,
val retryAt: Instant?,
) : IOException() {
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 java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue
@@ -41,11 +42,7 @@ class FileLogger(
}
val isEnabled: Boolean
get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex()
private var flushJob: Job? = null
@@ -55,7 +52,7 @@ class FileLogger(
return
}
val text = buildString {
append(dateFormat.format(Date()))
append(dateTimeFormatter.format(LocalDateTime.now()))
append(": ")
if (e != null) {
append("E!")

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository
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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -28,10 +29,16 @@ interface MangaRepository {
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<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.config.ConfigKey
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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -49,6 +50,9 @@ class RemoteMangaRepository(
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) {
@@ -58,6 +62,12 @@ class RemoteMangaRepository(
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String
get() = parser.domain
set(value) {

View File

@@ -204,6 +204,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean
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
get() {
if (isBackgroundNetworkRestricted()) {
@@ -295,13 +298,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
var readerColorFilter: ReaderColorFilter?
get() {
get() = runCatching {
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
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) {
prefs.edit {
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_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_DETAILS_TAB = "details_tab"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.annotation.CallSuper
@@ -8,7 +10,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
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?) {
(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,
visibleItemCount: Int
) = 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 org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.format
import java.util.Date
import java.time.LocalDate
import java.time.format.DateTimeFormatter
sealed class DateTimeAgo {
@@ -74,32 +73,22 @@ sealed class DateTimeAgo {
}
}
class Absolute(private val date: Date) : DateTimeAgo() {
private val day = date.daysDiff(0)
data class Absolute(private val date: LocalDate) : DateTimeAgo() {
override fun format(resources: Resources): String {
return if (date.time == 0L) {
return if (date == EPOCH_DATE) {
resources.getString(R.string.unknown)
} else {
date.format("d MMMM")
date.format(formatter)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
override fun toString() = "abs_${date.toEpochDay()}"
other as Absolute
return day == other.day
companion object {
// TODO: Use Java 9's LocalDate.EPOCH.
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() {

View File

@@ -12,4 +12,5 @@ val SortOrder.titleRes: Int
SortOrder.RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest
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.isCheckedIconVisible = true
chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check)
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)

View File

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

View File

@@ -17,6 +17,7 @@ import android.content.SyncResult
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Build
@@ -29,6 +30,7 @@ import android.view.Window
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -37,6 +39,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
@@ -45,7 +48,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException
import okio.use
import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
@@ -53,6 +58,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong
val Context.activityManager: ActivityManager?
@@ -230,3 +236,18 @@ fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >
} else {
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
import android.annotation.SuppressLint
import android.text.format.DateUtils
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
@SuppressLint("SimpleDateFormat")
fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this)
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
// 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(
time, System.currentTimeMillis(), minResolution,
)
fun Date.daysDiff(other: Long): Int {
val thisDay = time / TimeUnit.DAYS.toMillis(1L)
val otherDay = other / TimeUnit.DAYS.toMillis(1L)
return (thisDay - otherDay).toInt()
}
fun Date.startOfDay(): Long {
val calendar = Calendar.getInstance()
calendar.time = this
calendar[Calendar.HOUR_OF_DAY] = 0
calendar[Calendar.MINUTE] = 0
calendar[Calendar.SECOND] = 0
calendar[Calendar.MILLISECOND] = 0
return calendar.timeInMillis
return when {
diffDays == 0L -> {
if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
else DateTimeAgo.Today
}
diffDays == 1L -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
else -> {
val diffMonths = localDate.until(now, ChronoUnit.MONTHS)
if (showMonths && diffMonths <= 6) {
DateTimeAgo.MonthsAgo(diffMonths.toInt())
} else {
DateTimeAgo.Absolute(localDate)
}
}
}
}

View File

@@ -3,18 +3,18 @@ package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.util.TypedValue
import androidx.annotation.Px
import androidx.core.util.TypedValueCompat
import kotlin.math.roundToInt
@Px
fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt()
fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt()
@Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density
fun Resources.resolveDp(dp: Float) = TypedValueCompat.dpToPx(dp, displayMetrics)
@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")
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.inputmethod.InputMethodManager
import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
@@ -153,3 +155,9 @@ fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
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 androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>,
private val pager: ViewPager2,
) : OnBackPressedCallback(false),
ActionModeListener,
OnLayoutChangeListener, View.OnGenericMotionListener {
@@ -74,6 +76,7 @@ class ChaptersBottomSheetMediator(
fun lock() {
lockCounter++
behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
}
fun unlock() {
@@ -82,5 +85,6 @@ class ChaptersBottomSheetMediator(
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.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.MenuHost
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
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.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
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.os.AppShortcutManager
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.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.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
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.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
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.ui.model.ChapterListItem
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.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -74,10 +80,18 @@ class DetailsActivity :
@Inject
lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var settings: AppSettings
private var buttonTip: WeakReference<ButtonTip>? = null
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?) {
super.onCreate(savedInstanceState)
@@ -93,24 +107,22 @@ class DetailsActivity :
if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior)
val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager)
actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator)
chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator)
bottomSheetMediator = bsMediator
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
}
onBackPressedDispatcher.addCallback(chaptersMenuProvider)
initPager()
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
@@ -124,19 +136,16 @@ class DetailsActivity :
},
),
)
viewModel.onShowToast.observeEvent(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
}
viewModel.isChaptersReversed.observe(
this,
MenuInvalidator(viewBinding.toolbarChapters ?: this),
)
val chaptersMenuInvalidator = MenuInvalidator(viewBinding.toolbarChapters ?: this)
viewModel.isChaptersReversed.observe(this, chaptersMenuInvalidator)
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
@@ -153,7 +162,7 @@ class DetailsActivity :
DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.containerChapters,
snackbarHost = viewBinding.pager,
appShortcutManager = appShortcutManager,
),
)
@@ -217,12 +226,11 @@ class DetailsActivity :
TransitionManager.beginDelayedTransition(toolbar, transition)
}
if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null
}
toolbar.menuView?.isVisible = isExpanded
viewBinding.buttonRead.isGone = isExpanded
}
@@ -293,6 +301,18 @@ class DetailsActivity :
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) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
@@ -326,9 +346,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar =
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show()
Snackbar.make(viewBinding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
startActivity(
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) {
val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return
@@ -353,17 +380,6 @@ class DetailsActivity :
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 val context: Context,
) : FlowCollector<List<ChapterListItem>?> {

View File

@@ -10,8 +10,6 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -23,13 +21,14 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
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.BaseListAdapter
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.drawableTop
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.isTextTruncated
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.parsers.model.Manga
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.reader.ui.ReaderActivity
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.SearchActivity
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsFragment :
@@ -121,7 +117,7 @@ class DetailsFragment :
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
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) {
@@ -181,28 +177,13 @@ class DetailsFragment :
ratingBar.isVisible = false
}
when (manga.state) {
MangaState.FINISHED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
infoLayout.textViewState.apply {
manga.state?.let { state ->
textAndVisible = resources.getString(state.titleResId)
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) {
infoLayout.textViewSource.isVisible = false
@@ -218,8 +199,7 @@ class DetailsFragment :
}
}
private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) {
val (chapters, newChapters) = data
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
@@ -227,19 +207,7 @@ class DetailsFragment :
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) {
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
infoLayout.textViewChapters.text = chaptersText
}
}

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
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.observeAsStateFlow
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.call
import org.koitharu.kotatsu.core.util.ext.computeSize
@@ -80,7 +82,7 @@ class DetailsViewModel @Inject constructor(
private val mangaId = intent.mangaId
private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>()
val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
@@ -234,7 +236,7 @@ class DetailsViewModel @Inject constructor(
fun deleteLocal() {
val m = details.value?.local?.manga
if (m == null) {
onShowToast.call(R.string.file_not_found)
errorEvent.call(FileNotFoundException())
return
}
launchLoadingJob(Dispatchers.Default) {
@@ -246,7 +248,7 @@ class DetailsViewModel @Inject constructor(
fun removeBookmark(bookmark: Bookmark) {
launchJob(Dispatchers.Default) {
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.view.LayoutInflater
import android.view.Menu
@@ -11,6 +10,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
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.observeEvent
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.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -57,9 +60,6 @@ class ChaptersFragment :
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
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.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
@@ -69,6 +69,12 @@ class ChaptersFragment :
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
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() {

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.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.time.Instant
data class DownloadState(
val manga: Manga,
@@ -72,7 +72,7 @@ data class DownloadState(
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)
}

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.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.time.Instant
import java.util.UUID
data class DownloadItemModel(
@@ -21,7 +21,7 @@ data class DownloadItemModel(
val max: Int,
val progress: Int,
val eta: Long,
val timestamp: Date,
val timestamp: Instant,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
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.util.ReversibleAction
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.daysDiff
import org.koitharu.kotatsu.core.util.ext.isEmpty
import org.koitharu.kotatsu.download.domain.DownloadState
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.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
@@ -225,7 +223,7 @@ class DownloadsViewModel @Inject constructor(
WorkInfo.State.ENQUEUED -> queued += item
else -> {
val date = timeAgo(item.timestamp)
val date = calculateTimeAgo(item.timestamp)
if (prevDate != 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(
EmptyState(
icon = R.drawable.ic_empty_common,

View File

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

View File

@@ -111,7 +111,7 @@ class ExploreFragment :
}
override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(SettingsActivity.newManageSourcesIntent(view.context))
startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
}
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.ListModel
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.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -126,24 +125,18 @@ class ExploreViewModel @Inject constructor(
randomLoading: Boolean,
newSources: Set<MangaSource>,
): List<ListModel> {
val result = ArrayList<ListModel>(sources.size + 4)
val result = ArrayList<ListModel>(sources.size + 3)
result += ExploreButtons(randomLoading)
if (recommendation != null) {
result += ListHeader(R.string.suggestions)
result += RecommendationsItem(recommendation)
}
if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.manage)
if (newSources.isNotEmpty()) {
result += TipModel(
key = TIP_NEW_SOURCES,
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,
)
}
result += ListHeader(
textRes = R.string.remote_sources,
buttonTextRes = R.string.catalog,
badge = if (newSources.isNotEmpty()) "" else null,
)
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
} else {
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.model.FavouriteCategory
import org.koitharu.kotatsu.list.domain.ListSortOrder
import java.util.Date
import java.time.Instant
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Date(createdAt),
createdAt = Instant.ofEpochMilli(createdAt),
isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary,
)

View File

@@ -46,6 +46,10 @@ abstract class FavouritesDao {
)
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
@Query(
"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.LoadingState
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -67,11 +68,28 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
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 {
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 availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null
@@ -96,6 +114,22 @@ class FilterCoordinator @Inject constructor(
)
}.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(
currentState.distinctUntilChangedBy { it.sortOrder },
flowOf(repository.sortOrders),
@@ -120,6 +154,18 @@ class FilterCoordinator @Inject constructor(
)
}.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(
currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(),
@@ -187,7 +233,32 @@ class FilterCoordinator @Inject constructor(
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) {
currentState.update { oldValue ->
oldValue.copy(
@@ -224,13 +306,16 @@ class FilterCoordinator @Inject constructor(
fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue ->
oldValue.copy(tags = tags)
oldValue.copy(
tags = tags,
tagsExclude = oldValue.tagsExclude - tags
)
}
}
fun reset() {
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 {
emit(PendingData(emptySet(), isLoading = true, error = null))
tryLoadLocales()
@@ -277,7 +351,18 @@ class FilterCoordinator @Inject constructor(
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 ->
val res = suggested.toMutableList()
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> emptyProperty() = FilterProperty<T>(emptyList(), emptySet(), false, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
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?) {
val tag = data as? MangaTag
if (tag == null) {
TagsCatalogSheet.show(parentFragmentManager)
TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
} else {
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.FilterProperty
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.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -15,10 +16,14 @@ interface MangaFilter : OnFilterChangedListener {
val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterTagsExcluded: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
val filterState: StateFlow<FilterProperty<MangaState>>
val filterContentRating: StateFlow<FilterProperty<ContentRating>>
val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel>

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.filter.ui
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.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -14,5 +15,9 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setTagExcluded(value: MangaTag, 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.util.ext.getDisplayMessage
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.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
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.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -31,8 +33,9 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import com.google.android.material.R as materialR
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener {
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
@@ -50,12 +53,16 @@ class FilterSheetFragment :
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
@@ -72,8 +79,14 @@ class FilterSheetFragment :
val filter = requireFilter()
when (data) {
is MangaState -> filter.setState(data, chip.isChecked)
is MangaTag -> filter.setTag(data, chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager)
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
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)
}
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>) {
val b = viewBinding ?: return
b.textViewStateTitle.isGone = value.isEmpty()
@@ -186,6 +244,26 @@ class FilterSheetFragment :
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
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.util.ext.observe
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.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@@ -30,7 +31,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
private val viewModel by viewModels<TagsCatalogViewModel>(
extrasProducer = {
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) {
val filter = (requireActivity() as FilterOwner).filter
filter.setTag(item.tag, !item.isChecked)
viewModel.handleTagClick(item.tag, item.isChecked)
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
@@ -90,7 +93,10 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickL
companion object {
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
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.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaTag
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor(
@Assisted filter: MangaFilter,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
@Assisted private val filter: MangaFilter,
@Assisted private val isExcluded: Boolean,
) : BaseViewModel() {
val searchQuery = MutableStateFlow("")
private val filterProperty: StateFlow<FilterProperty<MangaTag>>
get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags
private val tags = combine(
filter.allTags,
filter.filterTags.map { it.selectedItems },
filterProperty.map { it.selectedItems },
) { all, selected ->
all.map { x ->
if (x is TagCatalogItem) {
@@ -52,9 +55,17 @@ class TagsCatalogViewModel @AssistedInject constructor(
}
}.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
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
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
import java.time.Instant
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
createdAt = Instant.ofEpochMilli(createdAt),
updatedAt = Instant.ofEpochMilli(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt(),
percent = percent,
)
)

View File

@@ -8,9 +8,10 @@ import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.startOfDay
import java.util.Date
import java.util.concurrent.TimeUnit
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import com.google.android.material.R as materialR
class HistoryListMenuProvider(
@@ -50,9 +51,9 @@ class HistoryListMenuProvider(
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) {
0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
1 -> Date().startOfDay()
2 -> 0L
0 -> Instant.now().minus(2, ChronoUnit.HOURS)
1 -> LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()
2 -> Instant.EPOCH
else -> return@setPositiveButton
}
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.observeAsFlow
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.util.ext.calculateTimeAgo
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.download.ui.worker.DownloadWorker
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.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.Date
import java.util.concurrent.TimeUnit
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@@ -100,13 +98,13 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit
fun clearHistory(minDate: Long) {
fun clearHistory(minDate: Instant) {
launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) {
val stringRes = if (minDate <= Instant.EPOCH) {
repository.clear()
R.string.history_cleared
} else {
repository.deleteAfter(minDate)
repository.deleteAfter(minDate.toEpochMilli())
R.string.removed_from_history
}
onActionDone.call(ReversibleAction(stringRes, null))
@@ -165,8 +163,8 @@ class HistoryListViewModel @Inject constructor(
}
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
ListSortOrder.UPDATED -> ListHeader(timeAgo(updatedAt))
ListSortOrder.NEWEST -> ListHeader(timeAgo(createdAt))
ListSortOrder.UPDATED -> ListHeader(calculateTimeAgo(updatedAt))
ListSortOrder.NEWEST -> ListHeader(calculateTimeAgo(createdAt))
ListSortOrder.PROGRESS -> ListHeader(
when (percent) {
1f -> R.string.status_completed
@@ -181,18 +179,4 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.NEW_CHAPTERS,
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>) {
listAdapter?.emit(list)
spanSizeLookup.invalidateCache()
viewBinding?.recyclerView?.let {
paginationListener?.postInvalidate(it)
}
}
private fun resolveException(e: Throwable) {

View File

@@ -14,22 +14,37 @@ import com.google.android.material.R as materialR
@CheckResult
fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
return if (counter > 0) {
val badgeDrawable = badge ?: initBadge(this)
badgeDrawable.number = counter
badgeDrawable.isVisible = true
badgeDrawable.align(this)
badgeDrawable
} else {
badge?.isVisible = false
badge
}
return bindBadgeImpl(badge, null, counter)
}
@CheckResult
fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? {
return bindBadgeImpl(badge, text, 0)
}
fun View.clearBadge(badge: BadgeDrawable?) {
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 {
val badge = BadgeDrawable.create(anchor.context)
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.isVisible
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -12,6 +13,8 @@ fun listHeaderAD(
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>(
{ inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
if (listener != null) {
binding.buttonMore.setOnClickListener {
listener.onListHeaderClick(item, it)
@@ -23,9 +26,11 @@ fun listHeaderAD(
if (item.buttonTextRes == 0) {
binding.buttonMore.isInvisible = true
binding.buttonMore.text = null
binding.buttonMore.clearBadge(badge)
} else {
binding.buttonMore.setText(item.buttonTextRes)
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")
data class ListHeader private constructor(
private val text: CharSequence?,
@StringRes private val textRes: Int,
private val dateTimeAgo: DateTimeAgo?,
private val textRaw: Any,
@StringRes val buttonTextRes: Int,
val payload: Any?,
val badge: String?,
) : ListModel {
constructor(
text: CharSequence,
@StringRes buttonTextRes: Int = 0,
payload: Any? = null,
) : this(text, 0, null, buttonTextRes, payload)
badge: String? = null,
) : this(textRaw = text, buttonTextRes, payload, badge)
constructor(
@StringRes textRes: Int,
@StringRes buttonTextRes: Int = 0,
payload: Any? = null,
) : this(null, textRes, null, buttonTextRes, payload)
badge: String? = null,
) : this(textRaw = textRes, buttonTextRes, payload, badge)
constructor(
dateTimeAgo: DateTimeAgo,
@StringRes buttonTextRes: Int = 0,
payload: Any? = null,
) : this(null, 0, dateTimeAgo, buttonTextRes, payload)
badge: String? = null,
) : this(textRaw = dateTimeAgo, buttonTextRes, payload, badge)
fun getText(context: Context): CharSequence? = when {
text != null -> text
textRes != 0 -> context.getString(textRes)
else -> dateTimeAgo?.format(context.resources)
fun getText(context: Context): CharSequence? = when (textRaw) {
is CharSequence -> textRaw
is Int -> if (textRaw != 0) context.getString(textRaw) else null
is DateTimeAgo -> textRaw.format(context.resources)
else -> null
}
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.LocalMangaUtil
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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -52,8 +53,11 @@ class LocalMangaRepository @Inject constructor(
private val locks = CompositeMutex2<Long>()
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 states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override var defaultSortOrder: SortOrder
get() = settings.localListOrder
@@ -75,6 +79,9 @@ class LocalMangaRepository @Inject constructor(
if (filter.tags.isNotEmpty()) {
list.retainAll { x -> x.containsTags(filter.tags) }
}
if (filter.tagsExclude.isNotEmpty()) {
list.removeAll { x -> x.containsAnyTag(filter.tags) }
}
when (filter.sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.local.data
import android.content.Context
import android.graphics.Bitmap
import android.os.StatFs
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -10,15 +11,17 @@ import kotlinx.coroutines.withContext
import okio.Source
import okio.buffer
import okio.sink
import okio.use
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.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import javax.inject.Inject
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 {
val statFs = StatFs(cacheDir.get().absolutePath)
statFs.availableBytes

View File

@@ -30,6 +30,12 @@ data class LocalManga(
return manga.tags.containsAll(tags)
}
fun containsAnyTag(tags: Set<MangaTag>): Boolean {
return tags.any { tag ->
manga.tags.contains(tag)
}
}
override fun toString(): String {
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.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
viewModel.feedCounter.observe(this, ::onFeedCounterChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) {
WelcomeSheet.show(supportFragmentManager)
@@ -278,10 +278,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
startActivity(IntentBuilder(this).manga(manga).build(), options)
}
private fun onCountersChanged(counters: Map<NavItem, Int>) {
counters.forEach { (navItem, counter) ->
navigationDelegate.setCounter(navItem, counter)
}
private fun onFeedCounterChanged(counter: Int) {
navigationDelegate.setCounter(NavItem.FEED, counter)
}
private fun onIncognitoModeChanged(isIncognito: Boolean) {

View File

@@ -4,15 +4,11 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
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.plus
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
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.ui.BaseViewModel
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.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
@@ -52,19 +47,8 @@ class MainViewModel @Inject constructor(
val appUpdate = appUpdateRepository.observeAvailableUpdate()
val counters = combine(
trackingRepository.observeUpdatedMangaCount(),
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>(),
)
val feedCounter = trackingRepository.observeUpdatedMangaCount()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, 0)
init {
launchJob {
@@ -87,8 +71,4 @@ class MainViewModel @Inject constructor(
fun setIncognitoMode(isEnabled: Boolean) {
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
import androidx.collection.LongSparseArray
import androidx.collection.contains
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
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)
}
operator fun contains(chapterId: Long) = indices.contains(chapterId)
private fun shiftIndices(delta: Int) {
for (i in 0 until indices.size()) {
val range = indices.valueAt(i)

View File

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

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toFile
import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -26,6 +25,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.use
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
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.RetainedLifecycleCoroutineScope
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.exists
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.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.concurrent.Volatile
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -65,11 +68,7 @@ class PageLoader @Inject constructor(
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor,
) : RetainedLifecycle.OnClearedListener {
init {
lifecycle.addOnClearedListener(this)
}
) {
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
@@ -77,17 +76,13 @@ class PageLoader @Inject constructor(
private val semaphore = Semaphore(3)
private val convertLock = Mutex()
private val prefetchLock = Mutex()
@Volatile
private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
override fun onCleared() {
synchronized(tasks) {
tasks.clear()
}
}
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository
&& settings.isPagesPreloadEnabled
@@ -131,21 +126,30 @@ class PageLoader @Inject constructor(
return loadPageAsync(page, force).await()
}
suspend fun convertInPlace(file: File) {
convertLock.withLock {
if (context.ramAvailable < file.length() * 2) {
return@withLock
}
runInterruptible(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
file.outputStream().use { out ->
image.compress(Bitmap.CompressFormat.PNG, 100, out)
suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock {
if (uri.isZipUri()) {
val bitmap = runInterruptible(Dispatchers.IO) {
ZipFile(uri.schemeSpecificPart).use { zip ->
val entry = zip.getEntry(uri.fragment)
context.ensureRamAtLeast(entry.size * 2)
zip.getInputStream(zip.getEntry(uri.fragment)).use {
BitmapFactory.decodeStream(it)
}
} 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 {
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
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.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import java.text.SimpleDateFormat
import java.util.Date
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import com.google.android.material.R as materialR
class ReaderInfoBarView @JvmOverloads constructor(
@@ -36,7 +37,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect()
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
@@ -52,7 +53,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
200,
)
private var timeText = timeFormat.format(Date())
private var timeText = timeFormat.format(LocalTime.now())
private var text: String = ""
private val innerHeight
@@ -181,7 +182,7 @@ class ReaderInfoBarView @JvmOverloads constructor(
private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
timeText = timeFormat.format(Date())
timeText = timeFormat.format(LocalTime.now())
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.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import java.util.Date
import java.time.Instant
import javax.inject.Inject
private const val BOUNDS_PAGE_OFFSET = 2
@@ -302,7 +302,7 @@ class ReaderViewModel @Inject constructor(
page = state.page,
scroll = state.scroll,
imageUrl = page.preview.ifNullOrEmpty { page.url },
createdAt = Date(),
createdAt = Instant.now(),
percent = computePercent(state.chapterId, state.page),
)
bookmarksRepository.addBookmark(bookmark)

View File

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

View File

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

View File

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

View File

@@ -28,8 +28,9 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import javax.inject.Inject
import javax.inject.Singleton
private const val DOMAIN = "shikimori.one"
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
@Singleton
@@ -199,15 +200,15 @@ class ShikimoriRepository @Inject constructor(
id = json.getLong("id"),
name = json.getString("name"),
altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"),
url = json.getString("url").toAbsoluteUrl("shikimori.me"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl(DOMAIN),
)
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"),
url = json.getString("url").toAbsoluteUrl("shikimori.me"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl(DOMAIN),
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> {
if (query.length < 3) {
return emptyList()

View File

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

View File

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

View File

@@ -10,8 +10,10 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
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.databinding.ActivitySourcesCatalogBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import javax.inject.Inject
@AndroidEntryPoint
@@ -31,6 +34,8 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
@Inject
lateinit var coil: ImageLoader
private var newSourcesSnackbar: Snackbar? = null
override val appBar: AppBarLayout
get() = viewBinding.appbar
@@ -45,6 +50,7 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter)
tabMediator.attach()
viewModel.content.observe(this, pagerAdapter)
viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged)
viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.pager),
@@ -80,4 +86,31 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
viewModel.performSearch(null)
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 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 ->
createListProducers(lc)
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
@@ -71,6 +75,12 @@ class SourcesCatalogViewModel @Inject constructor(
}
}
fun skipNewSources() {
launchJob {
repository.assimilateNewSources()
}
}
@MainThread
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
val types = EnumSet.allOf(ContentType::class.java)

View File

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

View File

@@ -211,12 +211,9 @@ class SuggestionsWorker @AssistedInject constructor(
}
val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = setOf(),
),
filter = MangaListFilter.Advanced.Builder(order)
.tags(setOfNotNull(tag))
.build(),
).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) {
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.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
import java.time.Instant
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
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,
chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt),
createdAt = Instant.ofEpochMilli(trackLog.createdAt),
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.RemoteMangaRepository
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.parsers.model.Manga
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.TrackingItem
import javax.inject.Inject
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class Tracker @Inject constructor(
private val settings: AppSettings,
@@ -77,7 +80,10 @@ class Tracker @Inject constructor(
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)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
@@ -99,7 +105,7 @@ class Tracker @Inject constructor(
}
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) {
suspend fun deleteTrack(mangaId: Long) = withMangaLock(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.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Provider
@@ -89,7 +89,7 @@ class TrackingRepository @Inject constructor(
result += MangaTracking(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
)
}
return result
@@ -101,7 +101,7 @@ class TrackingRepository @Inject constructor(
return MangaTracking(
manga = manga,
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
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
data class MangaTracking(
val manga: Manga,
val lastChapterId: Long,
val lastCheck: Date?,
val lastCheck: Instant?,
) {
fun isEmpty(): Boolean {
return lastChapterId == 0L

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.tracker.domain.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
data class TrackingLogItem(
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date,
val createdAt: Instant,
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.util.ext.MutableEventFlow
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.ui.model.EmptyState
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.toFeedItem
import org.koitharu.kotatsu.tracker.work.TrackWorker
import java.util.Date
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@@ -99,7 +97,7 @@ class FeedViewModel @Inject constructor(
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.createdAt)
val date = calculateTimeAgo(item.createdAt)
if (prevDate != date) {
destination += ListHeader(date)
}
@@ -115,17 +113,4 @@ class FeedViewModel @Inject constructor(
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_constraintTop_toBottomOf="@id/appbar">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container_chapters"
android:name="org.koitharu.kotatsu.details.ui.ChaptersFragment"
<LinearLayout
android:layout_width="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>

View File

@@ -98,12 +98,18 @@
</FrameLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container_chapters"
android:name="org.koitharu.kotatsu.details.ui.ChaptersFragment"
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="@style/Widget.Material3.TabLayout.Secondary"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/fragment_chapters" />
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>

View File

@@ -12,6 +12,7 @@
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollIndicators="top"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
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
android:id="@+id/textView_state"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center_horizontal"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone"
tools:drawableTopCompat="@drawable/ic_state_finished"
tools:text="Completed"
@@ -31,13 +28,10 @@
<TextView
android:id="@+id/textView_chapters"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center_horizontal"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone"
app:drawableTopCompat="@drawable/ic_book_page"
tools:text="52 chapters"
@@ -45,28 +39,22 @@
<TextView
android:id="@+id/textView_nsfw"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:text="@string/nsfw"
android:textSize="12sp"
android:visibility="gone"
app:drawableTopCompat="@drawable/ic_alert_outline"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_source"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone"
app:drawableTopCompat="@drawable/ic_web"
tools:text="Source"
@@ -74,13 +62,10 @@
<TextView
android:id="@+id/textView_size"
style="@style/Widget.Kotatsu.TextView.Indicator.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"
android:textSize="12sp"
android:visibility="gone"
app:drawableTopCompat="@drawable/ic_storage"
tools:text="1.8 GiB"

View File

@@ -126,6 +126,28 @@
tools:text="@string/error_multiple_genres_not_supported"
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
android:id="@+id/textView_state_title"
android:layout_width="match_parent"
@@ -148,6 +170,28 @@
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
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>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
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
android:id="@+id/action_search"
@@ -22,6 +23,14 @@
android:title="@string/filter"
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
android:id="@+id/action_source_settings"
android:orderInCategory="50"

View File

@@ -181,4 +181,106 @@
<string name="text_history_holder_primary">كل ما تقرأه سيعرض هنا</string>
<string name="text_search_holder_secondary">حاول إعادة صياغة الكلمات.</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>

View File

@@ -543,4 +543,12 @@
<string name="restore">Restaurar</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="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>

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="backup_date_">Petsa ng pag-backup: %s</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>

View File

@@ -505,4 +505,38 @@
<string name="last_successful_backup">Cadangan sukses terakhir: %s</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="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>

View File

@@ -268,7 +268,7 @@
<string name="show_reading_indicators">Mostrare gli indicatori di progresso della lettura</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="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="show_all">Mostra tutto</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>
<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="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="discard">Abbandona</string>
<string name="language">Lingua</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="show_suspicious_content">Mostra il contenuto sospetto</string>
<string name="theme_name_dynamic">Dinamico</string>
@@ -366,7 +366,181 @@
<string name="theme_name_rikka">Rikka</string>
<string name="theme_name_miku">Miku</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="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>

View File

@@ -11,9 +11,9 @@
<item quantity="other">%1$d capítulos</item>
</plurals>
<plurals name="new_chapters">
<item quantity="one">%1$d novo capítulo</item>
<item quantity="many">%1$d novos capítulos</item>
<item quantity="other">%1$d novos capítulos</item>
<item quantity="one">%1$d capítulo novo</item>
<item quantity="many">%1$d capítulos novos</item>
<item quantity="other">%1$d capítulos novos</item>
</plurals>
<plurals name="minutes_ago">
<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