Compare commits

..

23 Commits

Author SHA1 Message Date
adffa800e8 Update parsers 2024-01-07 12:08:04 +02:00
3acca44b5e Update parsers 2024-01-06 15:33:49 +02:00
c7da4feb8f Fix details bottom sheet menu 2024-01-06 15:18:49 +02:00
baee9bee0e Update shikimori domain 2024-01-06 14:27:07 +02:00
ec41d36508 Fix converting bitmaps in local manga 2024-01-06 14:13:56 +02:00
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
9eec9a9957 Fix favorites backup #621 2024-01-05 16:40:48 +02:00
a4966b4661 Update parsers 2024-01-05 16:29:22 +02:00
58e570601d PageLoader improvements 2024-01-05 10:46:32 +02:00
7247cba855 Improve pages preview on details screen 2024-01-04 16:59:39 +02:00
d6012f9ddd Reset filter menu action 2024-01-04 11:05:09 +02:00
2eedd0b4a8 Fix filter chips 2024-01-04 10:37:06 +02:00
5e6da9bb1c Pages thumbnails on details screen 2024-01-03 19:47:35 +02:00
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
89 changed files with 1352 additions and 375 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 610 versionCode = 612
versionName = '6.5.4' versionName = '6.6.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:ea095084cc') { implementation('com.github.KotatsuApp:kotatsu-parsers:0e8579017b') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -56,6 +57,14 @@ val MangaState.iconResId: Int
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
} }
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,6 +204,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
val defaultDetailsTab: Int
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
val isContentPrefetchEnabled: Boolean val isContentPrefetchEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
@@ -559,6 +562,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_DETAILS_TAB = "details_tab"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,6 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -23,7 +21,6 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -42,7 +39,6 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -75,7 +71,6 @@ import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorShee
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class DetailsFragment : class DetailsFragment :
@@ -122,7 +117,7 @@ class DetailsFragment :
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
combine(viewModel.chapters, viewModel.newChaptersCount, ::Pair).observe(viewLifecycleOwner, ::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -204,8 +199,7 @@ class DetailsFragment :
} }
} }
private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) { private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val (chapters, newChapters) = data
val infoLayout = requireViewBinding().infoLayout val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
@@ -213,19 +207,7 @@ class DetailsFragment :
val count = chapters.countChaptersByBranch() val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count) val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) { infoLayout.textViewChapters.text = chaptersText
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -545,4 +545,10 @@
<string name="sync_auth">Iniciar sesión en la cuenta de sincronización</string> <string name="sync_auth">Iniciar sesión en la cuenta de sincronización</string>
<string name="by_name_reverse">Nombre invertido</string> <string name="by_name_reverse">Nombre invertido</string>
<string name="state_upcoming">Próximamente</string> <string name="state_upcoming">Próximamente</string>
<string name="rating_safe">Seguro</string>
<string name="rating_suggestive">Sugestivo</string>
<string name="genres_exclude">Excluir los géneros</string>
<string name="rating_adult">Adulto</string>
<string name="content_rating">Clasificación del contenido</string>
<string name="default_tab">Pestaña por defecto</string>
</resources> </resources>

View File

@@ -1,2 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <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

@@ -410,4 +410,33 @@
<string name="done">Tehtud</string> <string name="done">Tehtud</string>
<string name="error_no_space_left">Ruumi pole ülejäänud seadmel</string> <string name="error_no_space_left">Ruumi pole ülejäänud seadmel</string>
<string name="sign_in">Logi sisse</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> </resources>

View File

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

View File

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

View File

@@ -545,4 +545,6 @@
<string name="backup_date_">Дата создания резервной копии: %s</string> <string name="backup_date_">Дата создания резервной копии: %s</string>
<string name="by_name_reverse">Имя (обратно)</string> <string name="by_name_reverse">Имя (обратно)</string>
<string name="state_upcoming">Ожидается</string> <string name="state_upcoming">Ожидается</string>
<string name="genres_exclude">Исключить жанры</string>
<string name="default_tab">Вкладка по умолчанию</string>
</resources> </resources>

View File

@@ -48,7 +48,7 @@
<string name="clear_search_history">Избриши историју претраге</string> <string name="clear_search_history">Избриши историју претраге</string>
<string name="gestures_only">Само покрети</string> <string name="gestures_only">Само покрети</string>
<string name="app_update_available">Доступна је нова верзија апликације</string> <string name="app_update_available">Доступна је нова верзија апликације</string>
<string name="open_in_browser">Отворите у веб прегледачу</string> <string name="open_in_browser">Отвори у веб прегледачу</string>
<string name="notification_sound">Звук обавештења</string> <string name="notification_sound">Звук обавештења</string>
<string name="vibration">Вибрација</string> <string name="vibration">Вибрација</string>
<string name="text_empty_holder_primary">Овде је некако празно…</string> <string name="text_empty_holder_primary">Овде је некако празно…</string>
@@ -341,7 +341,7 @@
<string name="saved_manga">Сачувана манга</string> <string name="saved_manga">Сачувана манга</string>
<string name="screenshots_block_all">Увек блокирај</string> <string name="screenshots_block_all">Увек блокирај</string>
<string name="new_sources_text">Доступни су нови извори манге</string> <string name="new_sources_text">Доступни су нови извори манге</string>
<string name="manga_error_description_pattern">Детаљи о грешци:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Покушајте да &lt;a href=%2$s&gt;отворите мангу у веб прегледачу&lt;/a&gt; да бисте били сигурни да је доступна на извору&lt;br&gt;2. Уверите се да користите &lt;a href=kotatsu://about&gt;latest version of Kotatsu&gt;најновију верзију Котатсу-а&lt;/a&gt;&lt;br&gt;3. Ако је доступно, пошаљите извештај о грешци програмерима.</string> <string name="manga_error_description_pattern">Детаљи о грешци:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Покушај да &lt;a href=%2$s&gt;отвориш мангу у веб прегледачу&lt;/a&gt; да би био сигуран да је доступна на извору&lt;br&gt;2. Увери се да користите &lt;a href=kotatsu://about&gt;latest version of Kotatsu&gt;најновију верзију Котатсу-а&lt;/a&gt;&lt;br&gt;3. Ако је доступно, пошаљи извештај о грешци програмерима.</string>
<string name="state_abandoned">Напуштен</string> <string name="state_abandoned">Напуштен</string>
<string name="zoom_mode_fit_height">Уклопи по висини</string> <string name="zoom_mode_fit_height">Уклопи по висини</string>
<string name="not_available">Недоступно</string> <string name="not_available">Недоступно</string>
@@ -543,4 +543,6 @@
<string name="restore">Поврати</string> <string name="restore">Поврати</string>
<string name="backup_date_">Датум резервне копије: %s</string> <string name="backup_date_">Датум резервне копије: %s</string>
<string name="sync_auth">Пријавите се за синхронизацију налога</string> <string name="sync_auth">Пријавите се за синхронизацију налога</string>
<string name="by_name_reverse">Обрнуто име</string>
<string name="state_upcoming">Излази</string>
</resources> </resources>

View File

@@ -81,4 +81,8 @@
<item>@string/frequency_twice_per_month</item> <item>@string/frequency_twice_per_month</item>
<item>@string/frequency_once_per_month</item> <item>@string/frequency_once_per_month</item>
</string-array> </string-array>
<string-array name="details_tabs" translatable="false">
<item>@string/chapters</item>
<item>@string/pages</item>
</string-array>
</resources> </resources>

View File

@@ -70,4 +70,8 @@
<item>14</item> <item>14</item>
<item>30</item> <item>30</item>
</string-array> </string-array>
<string-array name="details_tabs_values" translatable="false">
<item>0</item>
<item>1</item>
</string-array>
</resources> </resources>

View File

@@ -550,4 +550,10 @@
<string name="backup_date_">Backup date: %s</string> <string name="backup_date_">Backup date: %s</string>
<string name="state_upcoming">Upcoming</string> <string name="state_upcoming">Upcoming</string>
<string name="by_name_reverse">Name reversed</string> <string name="by_name_reverse">Name reversed</string>
<string name="content_rating">Content rating</string>
<string name="genres_exclude">Exclude genres</string>
<string name="rating_safe">Safe</string>
<string name="rating_suggestive">Suggestive</string>
<string name="rating_adult">Adult</string>
<string name="default_tab">Default tab</string>
</resources> </resources>

View File

@@ -220,6 +220,16 @@
<item name="android:textAppearance">?textAppearanceLabelMedium</item> <item name="android:textAppearance">?textAppearanceLabelMedium</item>
</style> </style>
<style name="Widget.Kotatsu.TextView.Indicator.Vertical" parent="Widget.MaterialComponents.TextView">
<item name="android:drawablePadding">4dp</item>
<item name="android:gravity">center</item>
<item name="android:textAlignment">center</item>
<item name="android:padding">4dp</item>
<item name="android:singleLine">true</item>
<item name="android:textSize">12sp</item>
<item name="android:elegantTextHeight">false</item>
</style>
<style name="ThemeOverlay.Kotatsu.MainToolbar" parent=""> <style name="ThemeOverlay.Kotatsu.MainToolbar" parent="">
<item name="colorControlHighlight">@color/selector_overlay</item> <item name="colorControlHighlight">@color/selector_overlay</item>
</style> </style>

View File

@@ -46,6 +46,19 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:title="@string/details">
<ListPreference
android:defaultValue="0"
android:entries="@array/details_tabs"
android:entryValues="@array/details_tabs_values"
android:key="details_tab"
android:title="@string/default_tab"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment" android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment"
android:key="nav_main" android:key="nav_main"