Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56c61d834 | ||
|
|
677f71dd84 | ||
|
|
3f90f88600 | ||
|
|
229a7c70d9 | ||
|
|
a2dbec98f9 | ||
|
|
3a02f8090e | ||
|
|
17519db44e | ||
|
|
99186bf269 | ||
|
|
9a65e40be1 | ||
|
|
f0add59f99 | ||
|
|
f18c182a6a | ||
|
|
68e9588f24 | ||
|
|
eea427216d | ||
|
|
8e9b89f6f0 | ||
|
|
4f3281be99 | ||
|
|
eb56a82702 | ||
|
|
089ccc9d15 | ||
|
|
12c1365513 | ||
|
|
7ecf9316e3 | ||
|
|
12e98ec36a | ||
|
|
22977fc7bc | ||
|
|
b387a49a4e | ||
|
|
dbbb0d0f64 | ||
|
|
0bbf2b752f | ||
|
|
14c1eacffa | ||
|
|
c2a0525bb8 | ||
|
|
4f502e580c | ||
|
|
7cb303966a | ||
|
|
3f0431f88b | ||
|
|
aad5601df1 | ||
|
|
8f2cf8141a | ||
|
|
eefd1129f7 | ||
|
|
5ed0f8b5a6 | ||
|
|
9b4aa4fd64 | ||
|
|
bbb226791b | ||
|
|
30ac4435d4 | ||
|
|
1b9dfe1901 | ||
|
|
808a6efd8f | ||
|
|
66ed19ed5a | ||
|
|
527a3cbd09 | ||
|
|
f22963b315 |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 31
|
||||
versionCode 372
|
||||
versionName '2.0'
|
||||
versionCode 377
|
||||
versionName '2.1.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -59,35 +59,36 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
|
||||
]
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.3.0'
|
||||
implementation 'androidx.room:room-ktx:2.3.0'
|
||||
kapt 'androidx.room:room-compiler:2.3.0'
|
||||
implementation 'androidx.room:room-runtime:2.4.0'
|
||||
implementation 'androidx.room:room-ktx:2.4.0'
|
||||
kapt 'androidx.room:room-compiler:2.4.0'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
implementation 'com.squareup.okio:okio:2.10.0'
|
||||
@@ -96,7 +97,7 @@ dependencies {
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
|
||||
|
||||
implementation 'io.insert-koin:koin-android:3.1.3'
|
||||
implementation 'io.insert-koin:koin-android:3.1.4'
|
||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.3'
|
||||
@@ -105,14 +106,14 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'com.google.truth:truth:1.1.3'
|
||||
testImplementation 'org.json:json:20210307'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.3'
|
||||
testImplementation 'org.json:json:20211205'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.3.0'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.4.0'
|
||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||
}
|
||||
@@ -99,6 +99,7 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:label="@string/downloads" />
|
||||
<activity android:name=".image.ui.ImageActivity"/>
|
||||
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
@@ -65,7 +66,7 @@ class KotatsuApp : Application() {
|
||||
trackerModule,
|
||||
settingsModule,
|
||||
readerModule,
|
||||
appWidgetModule
|
||||
appWidgetModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,5 +87,12 @@ class KotatsuApp : Application() {
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
.detectWrongFragmentContainer()
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package org.koitharu.kotatsu.base.domain
|
||||
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.core.exceptions.GraphQLException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.parseJson
|
||||
|
||||
|
||||
open class MangaLoaderContext(
|
||||
private val okHttp: OkHttpClient,
|
||||
val cookieJar: CookieJar
|
||||
val cookieJar: CookieJar,
|
||||
) : KoinComponent {
|
||||
|
||||
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||
@@ -24,7 +30,7 @@ open class MangaLoaderContext(
|
||||
|
||||
suspend fun httpPost(
|
||||
url: String,
|
||||
form: Map<String, String>
|
||||
form: Map<String, String>,
|
||||
): Response {
|
||||
val body = FormBody.Builder()
|
||||
form.forEach { (k, v) ->
|
||||
@@ -38,7 +44,7 @@ open class MangaLoaderContext(
|
||||
|
||||
suspend fun httpPost(
|
||||
url: String,
|
||||
payload: String
|
||||
payload: String,
|
||||
): Response {
|
||||
val body = FormBody.Builder()
|
||||
payload.split('&').forEach {
|
||||
@@ -55,10 +61,24 @@ open class MangaLoaderContext(
|
||||
return okHttp.newCall(request.build()).await()
|
||||
}
|
||||
|
||||
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SCHEME_HTTP = "http"
|
||||
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||
val body = JSONObject()
|
||||
body.put("operationName", null)
|
||||
body.put("variables", JSONObject())
|
||||
body.put("query", "{${query}}")
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = body.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.post(requestBody)
|
||||
.url(endpoint)
|
||||
val json = okHttp.newCall(request.build()).await().parseJson()
|
||||
json.optJSONArray("errors")?.let {
|
||||
if (it.length() != 0) {
|
||||
throw GraphQLException(it)
|
||||
}
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ object MangaProviderFactory {
|
||||
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toList() - MangaSource.LOCAL
|
||||
val order = settings.sourcesOrder
|
||||
val hidden = settings.hiddenSources
|
||||
val sorted = list.sortedBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
@@ -16,6 +15,7 @@ object MangaProviderFactory {
|
||||
return if (includeHidden) {
|
||||
sorted
|
||||
} else {
|
||||
val hidden = settings.hiddenSources
|
||||
sorted.filterNot { x ->
|
||||
x.name in hidden
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
|
||||
@@ -6,11 +6,10 @@ import android.text.InputFilter
|
||||
import android.view.LayoutInflater
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
||||
|
||||
class TextInputDialog private constructor(
|
||||
private val delegate: AlertDialog
|
||||
private val delegate: AlertDialog,
|
||||
) : DialogInterface by delegate {
|
||||
|
||||
fun show() = delegate.show()
|
||||
@@ -33,7 +32,7 @@ class TextInputDialog private constructor(
|
||||
}
|
||||
|
||||
fun setHint(@StringRes hintResId: Int): Builder {
|
||||
binding.inputLayout.hint = binding.root.context.getString(hintResId)
|
||||
binding.inputEdit.hint = binding.root.context.getString(hintResId)
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -64,7 +63,7 @@ class TextInputDialog private constructor(
|
||||
listener: (DialogInterface, String) -> Unit
|
||||
): Builder {
|
||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
||||
listener(dialog, binding.inputEdit.text.toString().orEmpty())
|
||||
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@Deprecated("")
|
||||
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
|
||||
RecyclerView.ViewHolder(binding.root), KoinComponent {
|
||||
|
||||
var boundData: T? = null
|
||||
private set
|
||||
|
||||
val context get() = itemView.context!!
|
||||
|
||||
fun bind(data: T, extra: E) {
|
||||
boundData = data
|
||||
onBind(data, extra)
|
||||
}
|
||||
|
||||
fun requireData(): T {
|
||||
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
|
||||
}
|
||||
|
||||
open fun onRecycled() = Unit
|
||||
|
||||
abstract fun onBind(data: T, extra: E)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeDrawable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val divider = context.getThemeDrawable(android.R.attr.listDivider)
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0)
|
||||
}
|
||||
|
||||
// TODO implement for horizontal lists on demand
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||
if (parent.layoutManager == null || divider == null) {
|
||||
return
|
||||
}
|
||||
canvas.save()
|
||||
val left: Int
|
||||
val right: Int
|
||||
if (parent.clipToPadding) {
|
||||
left = parent.paddingLeft
|
||||
right = parent.width - parent.paddingRight
|
||||
canvas.clipRect(
|
||||
left, parent.paddingTop, right,
|
||||
parent.height - parent.paddingBottom
|
||||
)
|
||||
} else {
|
||||
left = 0
|
||||
right = parent.width
|
||||
}
|
||||
|
||||
var previous: RecyclerView.ViewHolder? = null
|
||||
for (child in parent.children) {
|
||||
val holder = parent.getChildViewHolder(child)
|
||||
if (previous != null && shouldDrawDivider(previous, holder)) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
val top: Int = bounds.top + child.translationY.roundToInt()
|
||||
val bottom: Int = top + divider.intrinsicHeight
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
previous = holder
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
protected abstract fun shouldDrawDivider(
|
||||
above: RecyclerView.ViewHolder,
|
||||
below: RecyclerView.ViewHolder,
|
||||
): Boolean
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* https://github.com/paetztm/recycler_view_headers
|
||||
*/
|
||||
class SectionItemDecoration(
|
||||
private val isSticky: Boolean,
|
||||
private val callback: Callback
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private var headerView: TextView? = null
|
||||
private var headerOffset: Int = 0
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (headerOffset == 0) {
|
||||
headerOffset = parent.resources.getDimensionPixelSize(R.dimen.header_height)
|
||||
}
|
||||
val pos = parent.getChildAdapterPosition(view)
|
||||
outRect.set(0, if (callback.isSection(pos)) headerOffset else 0, 0, 0)
|
||||
}
|
||||
|
||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDrawOver(c, parent, state)
|
||||
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
|
||||
headerView = it
|
||||
}
|
||||
fixLayoutSize(textView, parent)
|
||||
|
||||
for (child in parent.children) {
|
||||
val pos = parent.getChildAdapterPosition(child)
|
||||
if (callback.isSection(pos)) {
|
||||
textView.text = callback.getSectionTitle(pos) ?: continue
|
||||
c.save()
|
||||
if (isSticky) {
|
||||
c.translate(
|
||||
0f,
|
||||
max(0f, (child.top - textView.height).toFloat())
|
||||
)
|
||||
} else {
|
||||
c.translate(
|
||||
0f,
|
||||
(child.top - textView.height).toFloat()
|
||||
)
|
||||
}
|
||||
textView.draw(c)
|
||||
c.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the header view to make sure its size is greater than 0 and will be drawn
|
||||
* https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
|
||||
*/
|
||||
private fun fixLayoutSize(view: View, parent: ViewGroup) {
|
||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
|
||||
val heightSpec =
|
||||
View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
|
||||
|
||||
val childWidth = ViewGroup.getChildMeasureSpec(
|
||||
widthSpec,
|
||||
parent.paddingLeft + parent.paddingRight,
|
||||
view.layoutParams.width
|
||||
)
|
||||
val childHeight = ViewGroup.getChildMeasureSpec(
|
||||
heightSpec,
|
||||
parent.paddingTop + parent.paddingBottom,
|
||||
view.layoutParams.height
|
||||
)
|
||||
view.measure(childWidth, childHeight)
|
||||
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun isSection(position: Int): Boolean
|
||||
|
||||
fun getSectionTitle(position: Int): CharSequence?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
|
||||
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
|
||||
|
||||
val messages = errors.map {
|
||||
it.getString("message")
|
||||
}
|
||||
|
||||
override val message: String
|
||||
get() = messages.joinToString("\n")
|
||||
}
|
||||
@@ -40,7 +40,8 @@ enum class MangaSource(
|
||||
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
||||
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
||||
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
|
||||
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java)
|
||||
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java),
|
||||
MANGADEX("MangaDex", null, MangaDexRepository::class.java),
|
||||
;
|
||||
|
||||
@get:Throws(NoBeanDefFoundException::class)
|
||||
|
||||
@@ -6,4 +6,5 @@ object CommonHeaders {
|
||||
const val USER_AGENT = "User-Agent"
|
||||
const val ACCEPT = "Accept"
|
||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||
const val COOKIE = "Cookie"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val networkModule
|
||||
@@ -28,4 +29,5 @@ val networkModule
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
factory { DownloadManagerHelper(get(), get()) }
|
||||
}
|
||||
@@ -34,4 +34,5 @@ val parserModule
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import androidx.collection.ArraySet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
import org.koitharu.kotatsu.utils.ext.mapIndexed
|
||||
import org.koitharu.kotatsu.utils.ext.stringIterator
|
||||
import java.util.*
|
||||
|
||||
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
@@ -20,76 +25,119 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
return if (offset == 0) search(query) else emptyList()
|
||||
return if (offset == 0) {
|
||||
search(query)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
val page = (offset / 12f).toIntUp().inc()
|
||||
val link = when {
|
||||
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
||||
else -> tags.joinToString(
|
||||
prefix = "/manga?",
|
||||
postfix = "&page=$page",
|
||||
separator = "&",
|
||||
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
||||
}
|
||||
val doc = loaderContext.httpGet(link).parseHtml()
|
||||
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
|
||||
separator = ",",
|
||||
prefix = "genres: [",
|
||||
postfix = "]"
|
||||
) { "\"it.key\"" }.orEmpty()
|
||||
val array = apiCall(
|
||||
"""
|
||||
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
|
||||
docs {
|
||||
mediaId
|
||||
title {
|
||||
be
|
||||
alt
|
||||
}
|
||||
rating
|
||||
poster
|
||||
genres
|
||||
slug
|
||||
mediaType
|
||||
status
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
).getJSONObject("getMediaList").getJSONArray("docs")
|
||||
return array.map { jo ->
|
||||
val mediaId = jo.getString("mediaId")
|
||||
val title = jo.getJSONObject("title")
|
||||
val href = "${jo.getString("mediaType")}/${jo.getString("slug")}"
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("data-src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
id = generateUid(mediaId),
|
||||
title = title.getString("be"),
|
||||
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||
.withDomain("cdn") + "?width=200&height=280",
|
||||
altTitle = title.getString("alt").takeUnless(String::isEmpty),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
rating = jo.getDouble("rating").toFloat() / 10f,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
publicUrl = "https://${getDomain()}/${href}",
|
||||
tags = jo.getJSONArray("genres").mapToTags(),
|
||||
state = when (jo.getString("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"finished" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
||||
val (type, slug) = manga.url.split('/')
|
||||
val details = apiCall(
|
||||
"""
|
||||
media(mediaType: $type, slug: "$slug") {
|
||||
mediaId
|
||||
title {
|
||||
be
|
||||
alt
|
||||
}
|
||||
description {
|
||||
be
|
||||
}
|
||||
status
|
||||
poster
|
||||
rating
|
||||
genres
|
||||
}
|
||||
""".trimIndent()
|
||||
).getJSONObject("media")
|
||||
val title = details.getJSONObject("title")
|
||||
val poster = details.getString("poster").removePrefix("/cdn")
|
||||
.withDomain("cdn")
|
||||
val chapters = apiCall(
|
||||
"""
|
||||
chapters(mediaId: "${details.getString("mediaId")}") {
|
||||
id
|
||||
chapter
|
||||
released
|
||||
}
|
||||
""".trimIndent()
|
||||
).getJSONArray("chapters")
|
||||
return manga.copy(
|
||||
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
||||
chapters = root.select("ul.series").flatMap { table ->
|
||||
table.select("li")
|
||||
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
||||
val href = a?.select("a")?.first()?.attr("href")
|
||||
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
||||
title = title.getString("be"),
|
||||
altTitle = title.getString("alt"),
|
||||
coverUrl = "$poster?width=200&height=280",
|
||||
largeCoverUrl = poster,
|
||||
description = details.getJSONObject("description").getString("be"),
|
||||
rating = details.getDouble("rating").toFloat() / 10f,
|
||||
tags = details.getJSONArray("genres").mapToTags(),
|
||||
state = when (details.getString("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"finished" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
chapters = chapters.map { jo ->
|
||||
val number = jo.getInt("chapter")
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = "Глава " + a.selectFirst("a")?.text().orEmpty(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
id = generateUid(jo.getString("id")),
|
||||
name = "Глава $number",
|
||||
number = number,
|
||||
url = "${manga.url}/read/$number",
|
||||
scanlator = null,
|
||||
uploadDate = jo.getLong("released"),
|
||||
branch = null,
|
||||
uploadDate = 0L,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
@@ -97,86 +145,115 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.withDomain()
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val data = script.html()
|
||||
val pos = data.indexOf("dataSource")
|
||||
if (pos == -1) {
|
||||
continue
|
||||
}
|
||||
val json = data.substring(pos).substringAfter('[').substringBefore(']')
|
||||
val domain = getDomain()
|
||||
return json.split(",").mapNotNull {
|
||||
it.trim()
|
||||
.removeSurrounding('"', '\'')
|
||||
.toRelativeUrl(domain)
|
||||
.takeUnless(String::isBlank)
|
||||
}.map { url ->
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
referer = fullUrl,
|
||||
source = source,
|
||||
)
|
||||
val (_, slug, _, number) = chapter.url.split('/')
|
||||
val chapterJson = apiCall(
|
||||
"""
|
||||
chapter(slug: "$slug", chapter: $number) {
|
||||
id
|
||||
images {
|
||||
large
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
).getJSONObject("chapter")
|
||||
val pages = chapterJson.getJSONArray("images")
|
||||
val chapterUrl = "https://${getDomain()}/${chapter.url}"
|
||||
return pages.mapIndexed { i, jo ->
|
||||
MangaPage(
|
||||
id = generateUid("${chapter.url}/$i"),
|
||||
url = jo.getString("large"),
|
||||
referer = chapterUrl,
|
||||
preview = jo.getString("thumbnail"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
||||
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
||||
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||
MangaTag(
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("data-name"),
|
||||
source = source
|
||||
)
|
||||
}
|
||||
val json = apiCall(
|
||||
"""
|
||||
getFilters(mediaType: manga) {
|
||||
genres
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val array = json.getJSONObject("getFilters").getJSONArray("genres")
|
||||
return array.mapToTags()
|
||||
}
|
||||
|
||||
private suspend fun search(query: String): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
|
||||
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.select("a").attr("href")
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
val json = apiCall(
|
||||
"""
|
||||
search(query: "$query", limit: 40) {
|
||||
id
|
||||
title {
|
||||
be
|
||||
en
|
||||
}
|
||||
poster
|
||||
url
|
||||
type
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val array = json.getJSONArray("search")
|
||||
return array.map { jo ->
|
||||
val mediaId = jo.getString("id")
|
||||
val title = jo.getJSONObject("title")
|
||||
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
id = generateUid(mediaId),
|
||||
title = title.getString("be"),
|
||||
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||
.withDomain("cdn") + "?width=200&height=280",
|
||||
altTitle = title.getString("en").takeUnless(String::isEmpty),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
publicUrl = "https://${getDomain()}/${href}",
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiCall(request: String): JSONObject {
|
||||
return loaderContext.graphQLQuery("https://api.${getDomain()}/graphql", request)
|
||||
.getJSONObject("data")
|
||||
}
|
||||
|
||||
private fun JSONArray.mapToTags(): Set<MangaTag> {
|
||||
|
||||
fun toTitle(slug: String): String {
|
||||
val builder = StringBuilder(slug)
|
||||
var capitalize = true
|
||||
for ((i, c) in builder.withIndex()) {
|
||||
when {
|
||||
c == '-' -> {
|
||||
builder.setCharAt(i, ' ')
|
||||
capitalize = true
|
||||
}
|
||||
capitalize -> {
|
||||
builder.setCharAt(i, c.uppercaseChar())
|
||||
capitalize = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
val result = ArraySet<MangaTag>(length())
|
||||
stringIterator().forEach {
|
||||
result.add(
|
||||
MangaTag(
|
||||
title = toTitle(it),
|
||||
key = it,
|
||||
source = source,
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -93,14 +93,14 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
description = json.getString("description"),
|
||||
chapters = chaptersList.mapIndexed { i, it ->
|
||||
val chid = it.getLong("id")
|
||||
val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch")
|
||||
val title = if (it.getString("title") == "null") "" else it.getString("title")
|
||||
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
|
||||
val title = it.optString("title", "null").takeUnless { it == "null" }
|
||||
MangaChapter(
|
||||
id = generateUid(chid),
|
||||
source = manga.source,
|
||||
url = "$baseChapterUrl$chid",
|
||||
uploadDate = it.getLong("date") * 1000,
|
||||
name = if (title.isEmpty()) volChap else "$volChap: $title",
|
||||
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
|
||||
number = totalChapters - i,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
|
||||
@@ -110,11 +110,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||
?: throw ParseException("Cannot find root")
|
||||
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
||||
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
|
||||
return manga.copy(
|
||||
description = root.selectFirst("div.manga-description")?.html(),
|
||||
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
|
||||
"data-full"
|
||||
),
|
||||
largeCoverUrl = coverImg?.attr("data-full"),
|
||||
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
|
||||
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
|
||||
.mapNotNull {
|
||||
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
|
||||
|
||||
@@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
return super.getList2(offset, query, tags, sortOrder).map {
|
||||
val cover = it.coverUrl
|
||||
if (cover.contains("_blur")) {
|
||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
it.copy(
|
||||
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||
isNsfw = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import android.os.Build
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
private const val CONTENT_RATING =
|
||||
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||
private const val LOCALE_FALLBACK = "en"
|
||||
|
||||
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
override val source = MangaSource.MANGADEX
|
||||
override val defaultDomain = "mangadex.org"
|
||||
|
||||
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.POPULARITY,
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga?limit=")
|
||||
append(PAGE_SIZE)
|
||||
append("&offset=")
|
||||
append(offset)
|
||||
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
|
||||
tags?.forEach { tag ->
|
||||
append("includedTags[]=")
|
||||
append(tag.key)
|
||||
append('&')
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append("title=")
|
||||
append(query.urlEncoded())
|
||||
append('&')
|
||||
}
|
||||
append(CONTENT_RATING)
|
||||
append("&order")
|
||||
append(when (sortOrder) {
|
||||
null,
|
||||
SortOrder.UPDATED,
|
||||
-> "[latestUploadedChapter]=desc"
|
||||
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||
else -> "[followedCount]=desc"
|
||||
})
|
||||
}
|
||||
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
return json.map { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val cover = relations["cover_art"]
|
||||
?.getJSONObject("attributes")
|
||||
?.getString("fileName")
|
||||
?.let {
|
||||
"https://uploads.$domain/covers/$id/$it"
|
||||
}
|
||||
Manga(
|
||||
id = generateUid(id),
|
||||
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
|
||||
"Title should not be null"
|
||||
},
|
||||
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
|
||||
url = id,
|
||||
publicUrl = "https://$domain/title/$id",
|
||||
rating = Manga.NO_RATING,
|
||||
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
|
||||
coverUrl = cover?.plus(".256.jpg").orEmpty(),
|
||||
largeCoverUrl = cover,
|
||||
description = attrs.optJSONObject("description")?.selectByLocale(),
|
||||
tags = attrs.getJSONArray("tags").mapToSet { tag ->
|
||||
MangaTag(
|
||||
title = tag.getJSONObject("attributes")
|
||||
.getJSONObject("name")
|
||||
.firstStringValue(),
|
||||
key = tag.getString("id"),
|
||||
source = source,
|
||||
)
|
||||
},
|
||||
state = when (jo.getStringOrNull("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"completed" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
author = (relations["author"] ?: relations["artist"])
|
||||
?.getJSONObject("attributes")
|
||||
?.getStringOrNull("name"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
|
||||
val domain = getDomain()
|
||||
val attrsDeferred = async {
|
||||
loaderContext.httpGet(
|
||||
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
|
||||
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||
}
|
||||
val feedDeferred = async {
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga/")
|
||||
append(manga.url)
|
||||
append("/feed")
|
||||
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
|
||||
append(CONTENT_RATING)
|
||||
}
|
||||
loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
}
|
||||
val mangaAttrs = attrsDeferred.await()
|
||||
val feed = feedDeferred.await()
|
||||
//2022-01-02T00:27:11+00:00
|
||||
val dateFormat = SimpleDateFormat(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
"yyyy-MM-dd'T'HH:mm:ssX"
|
||||
} else {
|
||||
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
|
||||
},
|
||||
Locale.ROOT
|
||||
)
|
||||
manga.copy(
|
||||
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||
?: manga.description,
|
||||
chapters = feed.mapNotNull { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
if (!attrs.isNull("externalUrl")) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val number = attrs.optInt("chapter", 0)
|
||||
MangaChapter(
|
||||
id = generateUid(id),
|
||||
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||
?: "Chapter #$number",
|
||||
number = number,
|
||||
url = id,
|
||||
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||
branch = locale.displayName.toTitleCase(locale),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val domain = getDomain()
|
||||
val attrs = loaderContext.httpGet("https://api.$domain/chapter/${chapter.url}")
|
||||
.parseJson()
|
||||
.getJSONObject("data")
|
||||
.getJSONObject("attributes")
|
||||
val pages = attrs.getJSONArray("pages")
|
||||
val prefix = "https://uploads.$domain/data/${attrs.getString("hash")}/"
|
||||
val referer = "https://$domain/"
|
||||
return List(pages.length()) { i ->
|
||||
val url = prefix + pages.getString(i)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = referer,
|
||||
preview = null, // TODO prefix + dataSaver.getString(i),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
|
||||
.getJSONArray("data")
|
||||
return tags.mapToSet { jo ->
|
||||
MangaTag(
|
||||
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
|
||||
key = jo.getString("id"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.firstStringValue() = values().next() as String
|
||||
|
||||
private fun JSONObject.selectByLocale(): String? {
|
||||
val preferredLocales = LocaleListCompat.getAdjustedDefault()
|
||||
repeat(preferredLocales.size()) { i ->
|
||||
val locale = preferredLocales.get(i)
|
||||
getStringOrNull(locale.language)?.let { return it }
|
||||
getStringOrNull(locale.toLanguageTag())?.let { return it }
|
||||
}
|
||||
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
|
||||
},
|
||||
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
||||
val a = li.select("a")
|
||||
val href = a.attr("href").ifEmpty {
|
||||
val href = a.attr("data-href").ifEmpty {
|
||||
parseFailed("Link is missing")
|
||||
}
|
||||
MangaChapter(
|
||||
|
||||
@@ -55,7 +55,7 @@ class MangareadRepository(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
publicUrl = href.inContextOf(div),
|
||||
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||
coverUrl = div.selectFirst("img")?.absUrl("data-src").orEmpty(),
|
||||
title = summary?.selectFirst("h3")?.text().orEmpty(),
|
||||
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||
@@ -107,16 +107,6 @@ class MangareadRepository(
|
||||
val root2 = doc.body().selectFirst("div.content-area")
|
||||
?.selectFirst("div.c-page")
|
||||
?: throw ParseException("Root2 not found")
|
||||
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
|
||||
?.attr("data-post")?.toLongOrNull()
|
||||
?: throw ParseException("Cannot obtain manga id")
|
||||
val doc2 = loaderContext.httpPost(
|
||||
"https://${getDomain()}/wp-admin/admin-ajax.php",
|
||||
mapOf(
|
||||
"action" to "manga_get_chapters",
|
||||
"manga" to mangaId.toString()
|
||||
)
|
||||
).parseHtml()
|
||||
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
|
||||
return manga.copy(
|
||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
||||
@@ -132,7 +122,7 @@ class MangareadRepository(
|
||||
?.select("p")
|
||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
||||
?.joinToString { it.html() },
|
||||
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
|
||||
chapters = root2.select("li").asReversed().mapIndexed { i, li ->
|
||||
val a = li.selectFirst("a")
|
||||
val href = a?.relUrl("href").orEmpty().ifEmpty {
|
||||
parseFailed("Link is missing")
|
||||
@@ -144,7 +134,7 @@ class MangareadRepository(
|
||||
url = href,
|
||||
uploadDate = parseChapterDate(
|
||||
dateFormat,
|
||||
doc2.selectFirst("span.chapter-release-date i")?.text()
|
||||
li.selectFirst("span.chapter-release-date i")?.text()
|
||||
),
|
||||
source = MangaSource.MANGAREAD,
|
||||
scanlator = null,
|
||||
|
||||
@@ -41,7 +41,7 @@ abstract class NineMangaRepository(
|
||||
append("&page=")
|
||||
}
|
||||
!tags.isNullOrEmpty() -> {
|
||||
append("/search/&category_id=")
|
||||
append("/search/?category_id=")
|
||||
for (tag in tags) {
|
||||
append(tag.key)
|
||||
append(',')
|
||||
|
||||
@@ -125,10 +125,10 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
number = chapters.length() - i,
|
||||
name = buildString {
|
||||
append("Том ")
|
||||
append(jo.getString("tome"))
|
||||
append(jo.optString("tome", "0"))
|
||||
append(". ")
|
||||
append("Глава ")
|
||||
append(jo.getString("chapter"))
|
||||
append(jo.optString("chapter", "0"))
|
||||
if (name.isNotEmpty()) {
|
||||
append(" - ")
|
||||
append(name)
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -79,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
||||
|
||||
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||
|
||||
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
||||
|
||||
val zoomMode by EnumPreferenceDelegate(
|
||||
@@ -107,6 +109,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
fun getStorageDir(context: Context): File? {
|
||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
@@ -141,7 +145,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
fun observe() = callbackFlow<String> {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
sendBlocking(key)
|
||||
trySendBlocking(key)
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
@@ -192,6 +196,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_RESTORE = "restore"
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
@@ -12,6 +13,7 @@ import androidx.core.text.parseAsHtml
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -27,6 +29,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaState
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
@@ -50,6 +53,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
binding.buttonFavorite.setOnClickListener(this)
|
||||
binding.buttonRead.setOnClickListener(this)
|
||||
binding.buttonRead.setOnLongClickListener(this)
|
||||
binding.coverCard.setOnClickListener(this)
|
||||
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
||||
@@ -58,14 +62,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
|
||||
private fun onMangaUpdated(manga: Manga) {
|
||||
with(binding) {
|
||||
|
||||
// Main
|
||||
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
|
||||
.referer(manga.publicUrl)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
|
||||
.lifecycle(viewLifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
loadCover(manga)
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitle
|
||||
textViewAuthor.textAndVisible = manga.author
|
||||
@@ -189,6 +187,17 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
)
|
||||
)
|
||||
}
|
||||
R.id.cover_card -> {
|
||||
val options = ActivityOptions.makeSceneTransitionAnimation(
|
||||
requireActivity(),
|
||||
binding.imageViewCover,
|
||||
binding.imageViewCover.transitionName,
|
||||
)
|
||||
startActivity(
|
||||
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
|
||||
options.toBundle()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,4 +248,22 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadCover(manga: Manga) {
|
||||
val currentCover = binding.imageViewCover.drawable
|
||||
val request = ImageRequest.Builder(context ?: return)
|
||||
.target(binding.imageViewCover)
|
||||
if (currentCover != null) {
|
||||
request.data(manga.largeCoverUrl ?: return)
|
||||
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
|
||||
.fallback(currentCover)
|
||||
} else {
|
||||
request.crossfade(true)
|
||||
.data(manga.coverUrl)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
}
|
||||
request.referer(manga.publicUrl)
|
||||
.lifecycle(viewLifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
class DetailsViewModel(
|
||||
intent: MangaIntent,
|
||||
@@ -127,9 +129,7 @@ class DetailsViewModel(
|
||||
selectedBranch.value = if (hist != null) {
|
||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||
} else {
|
||||
manga.chapters
|
||||
?.groupBy { it.branch }
|
||||
?.maxByOrNull { it.value.size }?.key
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
mangaData.value = manga
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
@@ -240,4 +240,21 @@ class DetailsViewModel(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val groups = chapters.groupBy { it.branch }
|
||||
val locale = Locale.getDefault()
|
||||
var language = locale.displayLanguage.toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
language = locale.displayName.toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||
.setPositiveButton(R.string.rename) { _, name ->
|
||||
callback.onRenameCategory(category, name)
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
callback.onRenameCategory(category, name)
|
||||
}
|
||||
}.create()
|
||||
.show()
|
||||
}
|
||||
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||
.setPositiveButton(R.string.add) { _, name ->
|
||||
callback.onCreateCategory(name)
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
callback.onCreateCategory(trimmed)
|
||||
}
|
||||
}.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
|
||||
val historyModule
|
||||
get() = module {
|
||||
|
||||
single { HistoryRepository(get(), get()) }
|
||||
single { HistoryRepository(get(), get(), get()) }
|
||||
viewModel { HistoryListViewModel(get(), get(), get()) }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
class HistoryRepository(
|
||||
private val db: MangaDatabase,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||
@@ -45,6 +47,9 @@ class HistoryRepository(
|
||||
}
|
||||
|
||||
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
||||
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
||||
return
|
||||
}
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.target.PoolableViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.indicator
|
||||
|
||||
class ImageActivity : BaseActivity<ActivityImageBinding>() {
|
||||
|
||||
private val coil: ImageLoader by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
loadImage(intent.data)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.toolbar.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
top = insets.top,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadImage(url: Uri?) {
|
||||
ImageRequest.Builder(this)
|
||||
.data(url)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.lifecycle(this)
|
||||
.target(SsivTarget(binding.ssiv))
|
||||
.indicator(binding.progressBar)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : PoolableViewTarget<SubsamplingScaleImageView> {
|
||||
|
||||
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
|
||||
|
||||
override fun onError(error: Drawable?) = setDrawable(error)
|
||||
|
||||
override fun onSuccess(result: Drawable) = setDrawable(result)
|
||||
|
||||
override fun onClear() = setDrawable(null)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (this === other) || (other is SsivTarget && view == other.view)
|
||||
}
|
||||
|
||||
override fun hashCode() = view.hashCode()
|
||||
|
||||
override fun toString() = "SsivTarget(view=$view)"
|
||||
|
||||
private fun setDrawable(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
|
||||
} else {
|
||||
view.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, url: String): Intent {
|
||||
return Intent(context, ImageActivity::class.java)
|
||||
.setData(Uri.parse(url))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
class AvailableFilters(
|
||||
val sortOrders: Set<SortOrder>,
|
||||
val tags: Set<MangaTag>,
|
||||
) {
|
||||
|
||||
val size: Int
|
||||
get() = sortOrders.size + tags.size
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as AvailableFilters
|
||||
if (sortOrders != other.sortOrders) return false
|
||||
if (tags != other.tags) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = sortOrders.hashCode()
|
||||
result = 31 * result + tags.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
data class MangaFilterConfig(
|
||||
val sortOrders: List<SortOrder>,
|
||||
val tags: List<MangaTag>,
|
||||
val currentFilter: MangaFilter?
|
||||
)
|
||||
@@ -22,19 +22,17 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
|
||||
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
@@ -42,10 +40,11 @@ import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
|
||||
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
|
||||
PaginationScrollListener.Callback, OnListItemClickListener<Manga>,
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
private var listAdapter: MangaListAdapter? = null
|
||||
private var filterAdapter: FilterAdapter2? = null
|
||||
private var paginationListener: PaginationScrollListener? = null
|
||||
private val spanResolver = MangaListSpanResolver()
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
@@ -78,6 +77,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
onRetryClick = ::resolveException,
|
||||
onTagRemoveClick = viewModel::onRemoveFilterTag
|
||||
)
|
||||
filterAdapter = FilterAdapter2(viewModel)
|
||||
paginationListener = PaginationScrollListener(4, this)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
@@ -94,8 +94,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
}
|
||||
with(binding.recyclerViewFilter) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(ItemTypeDividerDecoration(view.context))
|
||||
addItemDecoration(SectionItemDecoration(false, this@MangaListFragment))
|
||||
adapter = filterAdapter
|
||||
}
|
||||
|
||||
(parentFragment as? RecycledViewPoolHolder)?.let {
|
||||
@@ -113,6 +112,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
override fun onDestroyView() {
|
||||
drawer = null
|
||||
listAdapter = null
|
||||
filterAdapter = null
|
||||
paginationListener = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
@@ -203,28 +203,21 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
protected fun onInitFilter(config: MangaFilterConfig) {
|
||||
binding.recyclerViewFilter.adapter = FilterAdapter(
|
||||
sortOrders = config.sortOrders,
|
||||
tags = config.tags,
|
||||
state = config.currentFilter,
|
||||
listener = this
|
||||
)
|
||||
protected fun onInitFilter(filter: List<FilterItem>) {
|
||||
filterAdapter?.items = filter
|
||||
drawer?.setDrawerLockMode(
|
||||
if (config.sortOrders.isEmpty() && config.tags.isEmpty()) {
|
||||
if (filter.isEmpty()) {
|
||||
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
|
||||
} else {
|
||||
DrawerLayout.LOCK_MODE_UNLOCKED
|
||||
}
|
||||
) ?: binding.dividerFilter?.let {
|
||||
it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty()
|
||||
it.isGone = filter.isEmpty()
|
||||
binding.recyclerViewFilter.isVisible = it.isVisible
|
||||
}
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
override fun onFilterChanged(filter: MangaFilter) = Unit
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||
binding.recyclerViewFilter.updatePadding(
|
||||
@@ -284,20 +277,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
final override fun isSection(position: Int): Boolean {
|
||||
return position == 0 || binding.recyclerViewFilter.adapter?.run {
|
||||
getItemViewType(position) != getItemViewType(position - 1)
|
||||
} ?: false
|
||||
}
|
||||
|
||||
final override fun getSectionTitle(position: Int): CharSequence? {
|
||||
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
|
||||
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
|
||||
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
|
||||
|
||||
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
package org.koitharu.kotatsu.list.ui
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.list.domain.AvailableFilters
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
|
||||
abstract class MangaListViewModel(
|
||||
private val settings: AppSettings
|
||||
) : BaseViewModel() {
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel(), OnFilterChangedListener {
|
||||
|
||||
abstract val content: LiveData<List<ListModel>>
|
||||
val filter = MutableLiveData<MangaFilterConfig>()
|
||||
val filter = MutableLiveData<List<FilterItem>>()
|
||||
val listMode = MutableLiveData<ListMode>()
|
||||
val gridScale = settings.observe()
|
||||
.filter { it == AppSettings.KEY_GRID_SIZE }
|
||||
@@ -37,7 +46,62 @@ abstract class MangaListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
open fun onRemoveFilterTag(tag: MangaTag) = Unit
|
||||
protected var currentFilter: MangaFilter = MangaFilter(null, emptySet())
|
||||
private set(value) {
|
||||
field = value
|
||||
onFilterChanged()
|
||||
}
|
||||
protected var availableFilters: AvailableFilters? = null
|
||||
private var filterJob: Job? = null
|
||||
|
||||
final override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
currentFilter = currentFilter.copy(sortOrder = item.order)
|
||||
}
|
||||
|
||||
final override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
val tags = if (item.isChecked) {
|
||||
currentFilter.tags - item.tag
|
||||
} else {
|
||||
currentFilter.tags + item.tag
|
||||
}
|
||||
currentFilter = currentFilter.copy(tags = tags)
|
||||
}
|
||||
|
||||
fun onRemoveFilterTag(tag: MangaTag) {
|
||||
val tags = currentFilter.tags
|
||||
if (tag !in tags) {
|
||||
return
|
||||
}
|
||||
currentFilter = currentFilter.copy(tags = tags - tag)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onFilterChanged() {
|
||||
val previousJob = filterJob
|
||||
filterJob = launchJob(Dispatchers.Default) {
|
||||
previousJob?.cancelAndJoin()
|
||||
filter.postValue(
|
||||
availableFilters?.run {
|
||||
val list = ArrayList<FilterItem>(size + 2)
|
||||
if (sortOrders.isNotEmpty()) {
|
||||
val selectedSort = currentFilter.sortOrder ?: sortOrders.first()
|
||||
list += FilterItem.Header(R.string.sort_order)
|
||||
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == selectedSort)
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
list += FilterItem.Header(R.string.genres)
|
||||
tags.sortedBy { it.title }.mapTo(list) {
|
||||
FilterItem.Tag(it, isChecked = it in currentFilter.tags)
|
||||
}
|
||||
}
|
||||
ensureActive()
|
||||
list
|
||||
}.orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun onRefresh()
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
class FilterAdapter(
|
||||
private val sortOrders: List<SortOrder> = emptyList(),
|
||||
private val tags: List<MangaTag> = emptyList(),
|
||||
state: MangaFilter?,
|
||||
private val listener: OnFilterChangedListener
|
||||
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
|
||||
|
||||
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
||||
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
|
||||
itemView.setOnClickListener {
|
||||
setCheckedSort(requireData())
|
||||
}
|
||||
}
|
||||
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
|
||||
itemView.setOnClickListener {
|
||||
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown viewType $viewType")
|
||||
}
|
||||
|
||||
override fun getItemCount() = sortOrders.size + tags.size
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder<*, Boolean, *>, position: Int) {
|
||||
when (holder) {
|
||||
is FilterSortHolder -> {
|
||||
val item = sortOrders[position]
|
||||
holder.bind(item, item == currentState.sortOrder)
|
||||
}
|
||||
is FilterTagHolder -> {
|
||||
val item = tags[position - sortOrders.size]
|
||||
holder.bind(item, item in currentState.tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (position) {
|
||||
in sortOrders.indices -> VIEW_TYPE_SORT
|
||||
else -> VIEW_TYPE_TAG
|
||||
}
|
||||
|
||||
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
|
||||
currentState = if (tag in currentState.tags) {
|
||||
if (!isChecked) {
|
||||
currentState.copy(tags = currentState.tags - tag)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (isChecked) {
|
||||
currentState.copy(tags = currentState.tags + tag)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
val index = tags.indexOf(tag)
|
||||
if (index in tags.indices) {
|
||||
notifyItemChanged(sortOrders.size + index)
|
||||
}
|
||||
listener.onFilterChanged(currentState)
|
||||
}
|
||||
|
||||
fun setCheckedSort(sort: SortOrder) {
|
||||
if (sort != currentState.sortOrder) {
|
||||
val oldItemPos = sortOrders.indexOf(currentState.sortOrder)
|
||||
val newItemPos = sortOrders.indexOf(sort)
|
||||
currentState = currentState.copy(sortOrder = sort)
|
||||
if (oldItemPos in sortOrders.indices) {
|
||||
notifyItemChanged(oldItemPos)
|
||||
}
|
||||
if (newItemPos in sortOrders.indices) {
|
||||
notifyItemChanged(newItemPos)
|
||||
}
|
||||
listener.onFilterChanged(currentState)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val VIEW_TYPE_SORT = 0
|
||||
const val VIEW_TYPE_TAG = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
|
||||
class FilterAdapter2(
|
||||
listener: OnFilterChangedListener,
|
||||
) : AsyncListDifferDelegationAdapter<FilterItem>(
|
||||
FilterDiffCallback(),
|
||||
filterSortDelegate(listener),
|
||||
filterTagDelegate(listener),
|
||||
filterHeaderDelegate(),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
|
||||
|
||||
fun filterSortDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Sort, FilterItem, ItemCheckableSingleBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onSortItemClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.order.titleRes)
|
||||
binding.root.isChecked = item.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
fun filterTagDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Tag, FilterItem, ItemCheckableMultipleBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onTagItemClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.text = item.tag.title
|
||||
binding.root.isChecked = item.isChecked
|
||||
}
|
||||
}
|
||||
|
||||
fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, FilterItem, ItemFilterHeaderBinding>(
|
||||
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.titleResId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
|
||||
class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||
return when {
|
||||
oldItem.javaClass != newItem.javaClass -> false
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.titleResId == newItem.titleResId
|
||||
}
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.tag == newItem.tag
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.order == newItem.order
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||
return when {
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked == newItem.isChecked
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected == newItem.isSelected
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
|
||||
val isCheckedChanged = when {
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked != newItem.isChecked
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected != newItem.isSelected
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
sealed interface FilterItem {
|
||||
|
||||
class Header(
|
||||
@StringRes val titleResId: Int,
|
||||
) : FilterItem
|
||||
|
||||
class Sort(
|
||||
val order: SortOrder,
|
||||
val isSelected: Boolean,
|
||||
) : FilterItem
|
||||
|
||||
class Tag(
|
||||
val tag: MangaTag,
|
||||
val isChecked: Boolean,
|
||||
) : FilterItem
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
||||
|
||||
class FilterSortHolder(parent: ViewGroup) :
|
||||
BaseViewHolder<SortOrder, Boolean, ItemCheckableSingleBinding>(
|
||||
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
) {
|
||||
|
||||
override fun onBind(data: SortOrder, extra: Boolean) {
|
||||
binding.root.setText(data.titleRes)
|
||||
binding.root.isChecked = extra
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||
|
||||
class FilterTagHolder(parent: ViewGroup) :
|
||||
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
|
||||
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
) {
|
||||
|
||||
val isChecked: Boolean
|
||||
get() = binding.root.isChecked
|
||||
|
||||
override fun onBind(data: MangaTag, extra: Boolean) {
|
||||
binding.root.text = data.title
|
||||
binding.root.isChecked = extra
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
interface OnFilterChangedListener {
|
||||
|
||||
fun interface OnFilterChangedListener {
|
||||
fun onSortItemClick(item: FilterItem.Sort)
|
||||
|
||||
fun onFilterChanged(filter: MangaFilter)
|
||||
fun onTagItemClick(item: FilterItem.Tag)
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@@ -74,7 +74,7 @@ class LocalListViewModel(
|
||||
launchLoadingJob {
|
||||
val contentResolver = context.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = MediaStoreCompat(contentResolver).getName(uri)
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
|
||||
@@ -13,6 +13,6 @@ val readerModule
|
||||
single { PagesCache(get()) }
|
||||
|
||||
viewModel { params ->
|
||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
|
||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
override fun onActivityResult(result: Boolean) {
|
||||
if (result) {
|
||||
viewModel.saveCurrentState(reader?.getCurrentState())
|
||||
viewModel.saveCurrentPage(contentResolver)
|
||||
viewModel.saveCurrentPage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.util.LongSparseArray
|
||||
import android.webkit.URLUtil
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.*
|
||||
@@ -23,10 +22,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
@@ -38,7 +36,8 @@ class ReaderViewModel(
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val shortcutsRepository: ShortcutsRepository,
|
||||
private val settings: AppSettings
|
||||
private val settings: AppSettings,
|
||||
private val downloadManagerHelper: DownloadManagerHelper,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
@@ -150,7 +149,7 @@ class ReaderViewModel(
|
||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||
}
|
||||
|
||||
fun saveCurrentPage(resolver: ContentResolver) {
|
||||
fun saveCurrentPage() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
val state = currentState.value ?: error("Undefined state")
|
||||
@@ -159,13 +158,8 @@ class ReaderViewModel(
|
||||
}?.toMangaPage() ?: error("Page not found")
|
||||
val repo = MangaRepository(page.source)
|
||||
val pageUrl = repo.getPageUrl(page)
|
||||
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
|
||||
val uri = file.inputStream().use { input ->
|
||||
val fileName = URLUtil.guessFileName(pageUrl, null, null)
|
||||
MediaStoreCompat(resolver).insertImage(fileName) {
|
||||
input.copyTo(it)
|
||||
}
|
||||
}
|
||||
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
|
||||
val uri = downloadManagerHelper.awaitDownload(downloadId)
|
||||
onPageSaved.postCall(uri)
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
class ReversedPageAnimTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(page: View, position: Float) {
|
||||
with(page) {
|
||||
val pageWidth = width
|
||||
when {
|
||||
position > 1 -> alpha = 0f
|
||||
position >= 0 -> {
|
||||
alpha = 1f
|
||||
translationX = 0f
|
||||
translationZ = 0f
|
||||
scaleX = 1 + FACTOR * position
|
||||
scaleY = 1f
|
||||
}
|
||||
position >= -1 -> {
|
||||
alpha = 1f
|
||||
translationX = pageWidth * -position
|
||||
translationZ = -1f
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
}
|
||||
else -> alpha = 0f
|
||||
override fun transformPage(page: View, position: Float) = with(page) {
|
||||
translationX = -position * width
|
||||
pivotX = width.toFloat()
|
||||
pivotY = height / 2f
|
||||
cameraDistance = 20000f
|
||||
when {
|
||||
position < -1f || position > 1f -> {
|
||||
alpha = 0f
|
||||
rotationY = 0f
|
||||
translationZ = -1f
|
||||
}
|
||||
position <= 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 0f
|
||||
translationZ = 0f
|
||||
}
|
||||
position > 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 120 * position
|
||||
translationZ = 2f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val FACTOR = 0.1f
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
@@ -16,6 +18,11 @@ class ReversedPageHolder(
|
||||
exceptionResolver: ExceptionResolver
|
||||
) : PageHolder(binding, loader, settings, exceptionResolver) {
|
||||
|
||||
init {
|
||||
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
|
||||
.gravity = Gravity.START or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(zoom: ZoomMode) {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
|
||||
@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
class PageAnimTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(page: View, position: Float) {
|
||||
page.apply {
|
||||
val pageWidth = width
|
||||
when {
|
||||
position < -1 -> alpha = 0f
|
||||
position <= 0 -> { // [-1,0]
|
||||
alpha = 1f
|
||||
translationX = 0f
|
||||
translationZ = 0f
|
||||
scaleX = 1 + FACTOR * position
|
||||
scaleY = 1f
|
||||
}
|
||||
position <= 1 -> { // (0,1]
|
||||
alpha = 1f
|
||||
translationX = pageWidth * -position
|
||||
translationZ = -1f
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
}
|
||||
else -> alpha = 0f
|
||||
override fun transformPage(page: View, position: Float) = with(page) {
|
||||
translationX = -position * width
|
||||
pivotX = 0f
|
||||
pivotY = height / 2f
|
||||
cameraDistance = 20000f
|
||||
when {
|
||||
position < -1f || position > 1f -> {
|
||||
alpha = 0f
|
||||
rotationY = 0f
|
||||
translationZ = -1f
|
||||
}
|
||||
position > 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 0f
|
||||
translationZ = 0f
|
||||
}
|
||||
position <= 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 120 * position
|
||||
translationZ = 2f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val FACTOR = 0.1f
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,20 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
open class PageHolder(
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: AppSettings, exceptionResolver: ExceptionResolver
|
||||
settings: AppSettings,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver),
|
||||
View.OnClickListener {
|
||||
|
||||
init {
|
||||
binding.ssiv.setOnImageEventListener(delegate)
|
||||
binding.buttonRetry.setOnClickListener(this)
|
||||
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
|
||||
}
|
||||
|
||||
override fun onBind(data: ReaderPage) {
|
||||
delegate.onBind(data.toMangaPage())
|
||||
binding.textViewNumber.text = (data.index + 1).toString()
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.view.MenuItem
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
|
||||
@@ -29,10 +28,6 @@ class RemoteListFragment : MangaListFragment() {
|
||||
return source.title
|
||||
}
|
||||
|
||||
override fun onFilterChanged(filter: MangaFilter) {
|
||||
viewModel.applyFilter(filter)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
inflater.inflate(R.menu.opt_list_remote, menu)
|
||||
|
||||
@@ -9,12 +9,10 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.ui.MangaFilterConfig
|
||||
import org.koitharu.kotatsu.list.domain.AvailableFilters
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
@@ -27,7 +25,6 @@ class RemoteListViewModel(
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private var appliedFilter: MangaFilter? = null
|
||||
private var loadingJob: Job? = null
|
||||
private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0)
|
||||
|
||||
@@ -68,16 +65,6 @@ class RemoteListViewModel(
|
||||
loadList(append = !mangaList.value.isNullOrEmpty())
|
||||
}
|
||||
|
||||
override fun onRemoveFilterTag(tag: MangaTag) {
|
||||
val filter = appliedFilter ?: return
|
||||
if (tag !in filter.tags) {
|
||||
return
|
||||
}
|
||||
applyFilter(
|
||||
filter.copy(tags = filter.tags - tag)
|
||||
)
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
if (hasNextPage.value && listError.value == null) {
|
||||
loadList(append = true)
|
||||
@@ -93,8 +80,8 @@ class RemoteListViewModel(
|
||||
listError.value = null
|
||||
val list = repository.getList2(
|
||||
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
||||
sortOrder = appliedFilter?.sortOrder,
|
||||
tags = appliedFilter?.tags,
|
||||
sortOrder = currentFilter.sortOrder,
|
||||
tags = currentFilter.tags,
|
||||
)
|
||||
if (!append) {
|
||||
mangaList.value = list
|
||||
@@ -111,26 +98,29 @@ class RemoteListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFilter(newFilter: MangaFilter) {
|
||||
appliedFilter = newFilter
|
||||
override fun onFilterChanged() {
|
||||
super.onFilterChanged()
|
||||
mangaList.value = null
|
||||
hasNextPage.value = false
|
||||
loadList(false)
|
||||
filter.value?.run {
|
||||
filter.value = copy(currentFilter = newFilter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFilterModel() = appliedFilter?.run {
|
||||
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
|
||||
private fun createFilterModel(): CurrentFilterModel? {
|
||||
val tags = currentFilter.tags
|
||||
return if (tags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFilter() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
val sorts = repository.sortOrders.sortedBy { it.ordinal }
|
||||
val tags = repository.getTags().sortedBy { it.title }
|
||||
filter.postValue(MangaFilterConfig(sorts, tags, appliedFilter))
|
||||
val sorts = repository.sortOrders
|
||||
val tags = repository.getTags()
|
||||
availableFilters = AvailableFilters(sorts, tags)
|
||||
onFilterChanged()
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.utils.ext.names
|
||||
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@@ -134,53 +132,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
settings.setStorageDir(context ?: return, file)
|
||||
}
|
||||
|
||||
private fun enableAppProtection(preference: SwitchPreference) {
|
||||
val ctx = preference.context ?: return
|
||||
val cancelListener =
|
||||
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
|
||||
|
||||
override fun onCancel(dialog: DialogInterface?) {
|
||||
settings.appPassword = null
|
||||
preference.isChecked = false
|
||||
preference.isEnabled = true
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
|
||||
}
|
||||
preference.isEnabled = false
|
||||
TextInputDialog.Builder(ctx)
|
||||
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
||||
.setHint(R.string.enter_password)
|
||||
.setNegativeButton(android.R.string.cancel, cancelListener)
|
||||
.setOnCancelListener(cancelListener)
|
||||
.setPositiveButton(android.R.string.ok) { d, password ->
|
||||
if (password.isBlank()) {
|
||||
cancelListener.onCancel(d)
|
||||
return@setPositiveButton
|
||||
}
|
||||
TextInputDialog.Builder(ctx)
|
||||
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
||||
.setHint(R.string.repeat_password)
|
||||
.setNegativeButton(android.R.string.cancel, cancelListener)
|
||||
.setOnCancelListener(cancelListener)
|
||||
.setPositiveButton(android.R.string.ok) { d2, password2 ->
|
||||
if (password == password2) {
|
||||
settings.appPassword = password.md5()
|
||||
preference.isChecked = true
|
||||
preference.isEnabled = true
|
||||
} else {
|
||||
cancelListener.onCancel(d2)
|
||||
Snackbar.make(
|
||||
listView,
|
||||
R.string.passwords_mismatch,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}.setTitle(preference.title)
|
||||
.create()
|
||||
.show()
|
||||
}.setTitle(preference.title)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.settings.backup.BackupViewModel
|
||||
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
|
||||
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
|
||||
|
||||
val settingsModule
|
||||
get() = module {
|
||||
@@ -25,4 +26,5 @@ val settingsModule
|
||||
}
|
||||
viewModel { ProtectSetupViewModel(get()) }
|
||||
viewModel { OnboardViewModel(get()) }
|
||||
viewModel { SourcesSettingsViewModel(get()) }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||
import java.util.*
|
||||
|
||||
class OnboardViewModel(
|
||||
@@ -27,9 +28,9 @@ class OnboardViewModel(
|
||||
|
||||
init {
|
||||
if (settings.isSourcesSelected) {
|
||||
selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale })
|
||||
selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale })
|
||||
} else {
|
||||
val deviceLocales = LocaleListCompat.getDefault().map { x ->
|
||||
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
|
||||
x.language
|
||||
}
|
||||
selectedLocales.retainAll(deviceLocales)
|
||||
@@ -64,7 +65,7 @@ class OnboardViewModel(
|
||||
} else null
|
||||
SourceLocale(
|
||||
key = key,
|
||||
title = locale?.getDisplayLanguage(locale)?.capitalize(locale),
|
||||
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
|
||||
isChecked = key in selectedLocales
|
||||
)
|
||||
}.sortedWith(SourceLocaleComparator())
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
|
||||
|
||||
class SourceViewHolder(parent: ViewGroup) :
|
||||
BaseViewHolder<MangaSource, Boolean, ItemSourceConfigBinding>(
|
||||
ItemSourceConfigBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
) {
|
||||
|
||||
override fun onBind(data: MangaSource, extra: Boolean) {
|
||||
binding.textViewTitle.text = data.title
|
||||
binding.switchToggle.isChecked = extra
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class SourcesAdapter(
|
||||
private val settings: AppSettings,
|
||||
private val onItemClickListener: OnListItemClickListener<MangaSource>,
|
||||
) : RecyclerView.Adapter<SourceViewHolder>() {
|
||||
|
||||
private val dataSet =
|
||||
MangaProviderFactory.getSources(settings, includeHidden = true).toMutableList()
|
||||
private val hiddenItems = settings.hiddenSources.mapNotNull {
|
||||
runCatching {
|
||||
MangaSource.valueOf(it)
|
||||
}.getOrNull()
|
||||
}.toMutableSet()
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = SourceViewHolder(parent).also(::onViewHolderCreated)
|
||||
|
||||
override fun getItemCount() = dataSet.size
|
||||
|
||||
override fun onBindViewHolder(holder: SourceViewHolder, position: Int) {
|
||||
val item = dataSet[position]
|
||||
holder.bind(item, !hiddenItems.contains(item))
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun onViewHolderCreated(holder: SourceViewHolder) {
|
||||
holder.binding.switchToggle.setOnCheckedChangeListener { _, it ->
|
||||
if (it) {
|
||||
hiddenItems.remove(holder.requireData())
|
||||
} else {
|
||||
hiddenItems.add(holder.requireData())
|
||||
}
|
||||
settings.hiddenSources = hiddenItems.mapToSet { x -> x.name }
|
||||
}
|
||||
holder.binding.imageViewConfig.setOnClickListener { v ->
|
||||
onItemClickListener.onItemClick(holder.requireData(), v)
|
||||
}
|
||||
holder.binding.imageViewHandle.setOnTouchListener { v, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
onItemClickListener.onItemLongClick(
|
||||
holder.requireData(),
|
||||
holder.itemView
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveItem(oldPos: Int, newPos: Int) {
|
||||
val item = dataSet.removeAt(oldPos)
|
||||
dataSet.add(newPos, item)
|
||||
notifyItemMoved(oldPos, newPos)
|
||||
settings.sourcesOrder = dataSet.map { it.ordinal }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class SourcesReorderCallback :
|
||||
ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) {
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val adapter = recyclerView.adapter as? SourcesAdapter ?: return false
|
||||
val oldPos = viewHolder.bindingAdapterPosition
|
||||
val newPos = target.bindingAdapterPosition
|
||||
adapter.moveItem(oldPos, newPos)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigItemDecoration
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
|
||||
OnListItemClickListener<MangaSource> {
|
||||
SourceConfigListener {
|
||||
|
||||
private lateinit var reorderHelper: ItemTouchHelper
|
||||
private val viewModel by viewModel<SourcesSettingsViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -39,11 +42,16 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val sourcesAdapter = SourceConfigAdapter(this)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
|
||||
adapter = SourcesAdapter(get(), this@SourcesSettingsFragment)
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(SourceConfigItemDecoration(view.context))
|
||||
adapter = sourcesAdapter
|
||||
reorderHelper.attachToRecyclerView(this)
|
||||
}
|
||||
viewModel.items.observe(viewLifecycleOwner) {
|
||||
sourcesAdapter.items = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -51,22 +59,6 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
// TODO handle changes in dialog
|
||||
// inflater.inflate(R.menu.opt_sources, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when(item.itemId) {
|
||||
R.id.action_languages -> {
|
||||
OnboardDialogFragment.show(parentFragmentManager)
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
@@ -75,14 +67,47 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaSource, view: View) {
|
||||
(activity as? SettingsActivity)?.openMangaSourceSettings(item)
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
||||
(activity as? SettingsActivity)?.openMangaSourceSettings(item.source)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: MangaSource, view: View): Boolean {
|
||||
reorderHelper.startDrag(
|
||||
binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.setEnabled(item.source, isEnabled)
|
||||
}
|
||||
|
||||
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
|
||||
reorderHelper.startDrag(holder)
|
||||
}
|
||||
|
||||
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
|
||||
viewModel.expandOrCollapse(header.localeId)
|
||||
}
|
||||
|
||||
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
|
||||
0,
|
||||
) {
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources(
|
||||
viewHolder.bindingAdapterPosition,
|
||||
target.bindingAdapterPosition,
|
||||
)
|
||||
return true
|
||||
|
||||
override fun canDropOver(
|
||||
recyclerView: RecyclerView,
|
||||
current: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder(
|
||||
current.bindingAdapterPosition,
|
||||
target.bindingAdapterPosition,
|
||||
)
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
import org.koitharu.kotatsu.utils.ext.move
|
||||
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||
import java.util.*
|
||||
|
||||
private const val KEY_ENABLED = "!"
|
||||
|
||||
class SourcesSettingsViewModel(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableLiveData<List<SourceConfigItem>>(emptyList())
|
||||
private val expandedGroups = HashSet<String?>()
|
||||
|
||||
init {
|
||||
buildList()
|
||||
}
|
||||
|
||||
fun reorderSources(oldPos: Int, newPos: Int): Boolean {
|
||||
val snapshot = items.value?.toMutableList() ?: return false
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
snapshot.move(oldPos, newPos)
|
||||
settings.sourcesOrder = snapshot.mapNotNull {
|
||||
(it as? SourceConfigItem.SourceItem)?.source?.ordinal
|
||||
}
|
||||
buildList()
|
||||
return true
|
||||
}
|
||||
|
||||
fun canReorder(oldPos: Int, newPos: Int): Boolean {
|
||||
val snapshot = items.value?.toMutableList() ?: return false
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
|
||||
settings.hiddenSources = if (isEnabled) {
|
||||
settings.hiddenSources - source.name
|
||||
} else {
|
||||
settings.hiddenSources + source.name
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
|
||||
fun expandOrCollapse(headerId: String?) {
|
||||
if (headerId in expandedGroups) {
|
||||
expandedGroups.remove(headerId)
|
||||
} else {
|
||||
expandedGroups.add(headerId)
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
|
||||
private fun buildList() {
|
||||
val sources = MangaProviderFactory.getSources(settings, includeHidden = true)
|
||||
val hiddenSources = settings.hiddenSources
|
||||
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
|
||||
if (it.name !in hiddenSources) {
|
||||
KEY_ENABLED
|
||||
} else {
|
||||
it.locale
|
||||
}
|
||||
}
|
||||
val result = ArrayList<SourceConfigItem>(sources.size + map.size + 1)
|
||||
val enabledSources = map.remove(KEY_ENABLED)
|
||||
if (!enabledSources.isNullOrEmpty()) {
|
||||
result += SourceConfigItem.Header(R.string.enabled_sources)
|
||||
enabledSources.mapTo(result) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
isEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (enabledSources?.size != sources.size) {
|
||||
result += SourceConfigItem.Header(R.string.available_sources)
|
||||
for ((key, list) in map) {
|
||||
val locale = if (key != null) {
|
||||
Locale(key)
|
||||
} else null
|
||||
list.sortBy { it.ordinal }
|
||||
val isExpanded = key in expandedGroups
|
||||
result += SourceConfigItem.LocaleGroup(
|
||||
localeId = key,
|
||||
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
|
||||
isExpanded = isExpanded,
|
||||
)
|
||||
if (isExpanded) {
|
||||
list.mapTo(result) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
isEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items.value = result
|
||||
}
|
||||
|
||||
private class LocaleKeyComparator : Comparator<String?> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
|
||||
.map { it.language }
|
||||
|
||||
override fun compare(a: String?, b: String?): Int {
|
||||
when {
|
||||
a == b -> return 0
|
||||
a == null -> return 1
|
||||
b == null -> return -1
|
||||
}
|
||||
val ai = deviceLocales.indexOf(a!!)
|
||||
val bi = deviceLocales.indexOf(b!!)
|
||||
return when {
|
||||
ai < 0 && bi < 0 -> a.compareTo(b)
|
||||
ai < 0 -> 1
|
||||
bi < 0 -> -1
|
||||
else -> ai.compareTo(bi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourceConfigAdapter(
|
||||
listener: SourceConfigListener,
|
||||
) : AsyncListDifferDelegationAdapter<SourceConfigItem>(
|
||||
SourceConfigDiffCallback(),
|
||||
sourceConfigHeaderDelegate(),
|
||||
sourceConfigGroupDelegate(listener),
|
||||
sourceConfigItemDelegate(listener),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.CompoundButton
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
|
||||
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.titleResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigGroupDelegate(
|
||||
listener: SourceConfigListener,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
|
||||
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener.onHeaderClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.text = item.title ?: getString(R.string.other)
|
||||
binding.root.isChecked = item.isExpanded
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun sourceConfigItemDelegate(
|
||||
listener: SourceConfigListener,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
val eventListener = object : View.OnClickListener, View.OnTouchListener,
|
||||
CompoundButton.OnCheckedChangeListener {
|
||||
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
|
||||
|
||||
override fun onTouch(v: View?, event: MotionEvent): Boolean {
|
||||
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||
listener.onItemEnabledChanged(item, isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
binding.imageViewConfig.setOnClickListener(eventListener)
|
||||
binding.switchToggle.setOnCheckedChangeListener(eventListener)
|
||||
binding.imageViewHandle.setOnTouchListener(eventListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.title
|
||||
binding.switchToggle.isChecked = item.isEnabled
|
||||
binding.imageViewHandle.isVisible = item.isEnabled
|
||||
binding.imageViewConfig.isVisible = item.isEnabled
|
||||
binding.root.updatePaddingRelative(
|
||||
start = if (item.isEnabled) 0 else binding.imageViewHandle.paddingStart * 2,
|
||||
end = if (item.isEnabled) 0 else binding.imageViewConfig.paddingEnd,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourceConfigDiffCallback : DiffUtil.ItemCallback<SourceConfigItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
|
||||
return when {
|
||||
oldItem.javaClass != newItem.javaClass -> false
|
||||
oldItem is SourceConfigItem.LocaleGroup && newItem is SourceConfigItem.LocaleGroup -> {
|
||||
oldItem.localeId == newItem.localeId
|
||||
}
|
||||
oldItem is SourceConfigItem.SourceItem && newItem is SourceConfigItem.SourceItem -> {
|
||||
oldItem.source == newItem.source
|
||||
}
|
||||
oldItem is SourceConfigItem.Header && newItem is SourceConfigItem.Header -> {
|
||||
oldItem.titleResId == newItem.titleResId
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration
|
||||
|
||||
class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) {
|
||||
|
||||
override fun shouldDrawDivider(
|
||||
above: RecyclerView.ViewHolder,
|
||||
below: RecyclerView.ViewHolder,
|
||||
): Boolean {
|
||||
return above.itemViewType != 0 && below.itemViewType != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
interface SourceConfigListener {
|
||||
|
||||
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
|
||||
|
||||
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
|
||||
|
||||
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.settings.sources.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
sealed interface SourceConfigItem {
|
||||
|
||||
class Header(
|
||||
@StringRes val titleResId: Int,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as Header
|
||||
return titleResId == other.titleResId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = titleResId
|
||||
}
|
||||
|
||||
class LocaleGroup(
|
||||
val localeId: String?,
|
||||
val title: String?,
|
||||
val isExpanded: Boolean,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as LocaleGroup
|
||||
|
||||
if (localeId != other.localeId) return false
|
||||
if (title != other.title) return false
|
||||
if (isExpanded != other.isExpanded) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = localeId?.hashCode() ?: 0
|
||||
result = 31 * result + (title?.hashCode() ?: 0)
|
||||
result = 31 * result + isExpanded.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class SourceItem(
|
||||
val source: MangaSource,
|
||||
val isEnabled: Boolean,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as SourceItem
|
||||
|
||||
if (source != other.source) return false
|
||||
if (isEnabled != other.isEnabled) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = source.hashCode()
|
||||
result = 31 * result + isEnabled.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.DownloadManager.Request.*
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.utils.ext.toFileNameSafe
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class DownloadManagerHelper(
|
||||
private val context: Context,
|
||||
private val cookieJar: CookieJar,
|
||||
) {
|
||||
|
||||
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
|
||||
|
||||
fun downloadPage(page: MangaPage, fullUrl: String): Long {
|
||||
val uri = fullUrl.toUri()
|
||||
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
|
||||
val dest = subDir + File.separator + uri.lastPathSegment
|
||||
val request = DownloadManager.Request(uri)
|
||||
.addRequestHeader(CommonHeaders.REFERER, page.referer)
|
||||
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
|
||||
.setNotificationVisibility(VISIBILITY_VISIBLE)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
request.allowScanningByMediaScanner()
|
||||
}
|
||||
return manager.enqueue(request)
|
||||
}
|
||||
|
||||
suspend fun awaitDownload(id: Long): Uri {
|
||||
getUriForDownloadedFile(id)?.let { return it } // fast path
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (
|
||||
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
|
||||
) {
|
||||
context.unregisterReceiver(this)
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
)
|
||||
cont.invokeOnCancellation {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
return checkNotNull(getUriForDownloadedFile(id))
|
||||
}
|
||||
|
||||
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
|
||||
manager.getUriForDownloadedFile(id)
|
||||
}
|
||||
|
||||
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
|
||||
cookies.forEachIndexed { index, cookie ->
|
||||
if (index > 0) append("; ")
|
||||
append(cookie.name).append('=').append(cookie.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.io.OutputStream
|
||||
|
||||
class MediaStoreCompat(private val contentResolver: ContentResolver) {
|
||||
|
||||
fun insertImage(
|
||||
fileName: String,
|
||||
block: (OutputStream) -> Unit
|
||||
): Uri? {
|
||||
val name = fileName.substringBeforeLast('.')
|
||||
val cv = ContentValues(7)
|
||||
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
|
||||
cv.put(MediaStore.Images.Media.TITLE, name)
|
||||
cv.put(
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
|
||||
)
|
||||
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1_000)
|
||||
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
var uri: Uri? = null
|
||||
try {
|
||||
uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
|
||||
contentResolver.openOutputStream(uri!!)?.use(block)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
cv.clear()
|
||||
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
contentResolver.update(uri, cv, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
uri?.let {
|
||||
contentResolver.delete(it, null, null)
|
||||
}
|
||||
uri = null
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
fun getName(uri: Uri): String? =
|
||||
(if (uri.scheme == "content") {
|
||||
contentResolver.query(uri, null, null, null, null)?.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}) ?: uri.path?.substringAfterLast('/')
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
|
||||
fun observeAutoOrientation() = callbackFlow<Boolean> {
|
||||
val observer = object : ContentObserver(Handler(activity.mainLooper)) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
sendBlocking(isAutoRotationEnabled)
|
||||
trySendBlocking(isAutoRotationEnabled)
|
||||
}
|
||||
}
|
||||
activity.contentResolver.registerContentObserver(
|
||||
|
||||
@@ -7,16 +7,16 @@ import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
|
||||
fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.crossfade(true)
|
||||
.target(this)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
|
||||
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
|
||||
|
||||
fun ImageResult.requireBitmap() = when (this) {
|
||||
is SuccessResult -> drawable.toBitmap()
|
||||
@@ -32,7 +32,10 @@ fun ImageResult.toBitmapOrNull() = when (this) {
|
||||
is ErrorResult -> null
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
|
||||
fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
|
||||
return setHeader(CommonHeaders.REFERER, referer)
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
|
||||
return listener(ImageRequestIndicatorListener(indicator))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.LongSparseArray
|
||||
import java.util.*
|
||||
|
||||
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
|
||||
clear()
|
||||
@@ -72,4 +73,12 @@ fun <T, K> Collection<T>.isDistinctBy(selector: (T) -> K): Boolean {
|
||||
}
|
||||
}
|
||||
return set.size == size
|
||||
}
|
||||
|
||||
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
|
||||
if (sourceIndex <= targetIndex) {
|
||||
Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
|
||||
} else {
|
||||
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
|
||||
|
||||
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
||||
delete()
|
||||
}
|
||||
|
||||
fun ContentResolver.resolveName(uri: Uri): String? {
|
||||
val fallback = uri.lastPathSegment
|
||||
if (uri.scheme != "content") {
|
||||
return fallback
|
||||
}
|
||||
query(uri, null, null, null, null)?.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name ->
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
fun <T> Iterator<T>.nextOrNull(): T? = if (hasNext()) next() else null
|
||||
|
||||
fun <T> Iterator<T>.toList(): List<T> {
|
||||
if (!hasNext()) {
|
||||
return emptyList()
|
||||
}
|
||||
val list = ArrayList<T>()
|
||||
while (hasNext()) list += next()
|
||||
return list
|
||||
}
|
||||
|
||||
fun <T> Iterator<T>.toSet(): Set<T> {
|
||||
if (!hasNext()) {
|
||||
return emptySet()
|
||||
}
|
||||
val list = LinkedHashSet<T>()
|
||||
while (hasNext()) list += next()
|
||||
return list
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext
|
||||
import androidx.collection.ArraySet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.utils.json.JSONIterator
|
||||
import org.koitharu.kotatsu.utils.json.JSONStringIterator
|
||||
import org.koitharu.kotatsu.utils.json.JSONValuesIterator
|
||||
import kotlin.contracts.contract
|
||||
|
||||
inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
|
||||
destination: C,
|
||||
@@ -16,10 +20,26 @@ inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
|
||||
return destination
|
||||
}
|
||||
|
||||
inline fun <R, C : MutableCollection<in R>> JSONArray.mapNotNullTo(
|
||||
destination: C,
|
||||
block: (JSONObject) -> R?
|
||||
): C {
|
||||
val len = length()
|
||||
for (i in 0 until len) {
|
||||
val jo = getJSONObject(i)
|
||||
destination.add(block(jo) ?: continue)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
|
||||
return mapTo(ArrayList(length()), block)
|
||||
}
|
||||
|
||||
inline fun <T> JSONArray.mapNotNull(block: (JSONObject) -> T?): List<T> {
|
||||
return mapNotNullTo(ArrayList(length()), block)
|
||||
}
|
||||
|
||||
fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> {
|
||||
val len = length()
|
||||
val result = ArrayList<T>(len)
|
||||
@@ -44,16 +64,7 @@ fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(na
|
||||
|
||||
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
|
||||
|
||||
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): JSONObject = array.getJSONObject(index++)
|
||||
|
||||
}
|
||||
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
|
||||
|
||||
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
||||
val len = length()
|
||||
@@ -63,4 +74,24 @@ fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
||||
result.add(block(jo))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun JSONObject.values(): Iterator<Any> = JSONValuesIterator(this)
|
||||
|
||||
fun JSONArray.associateByKey(key: String): Map<String, JSONObject> {
|
||||
val destination = LinkedHashMap<String, JSONObject>(length())
|
||||
repeat(length()) { i ->
|
||||
val item = getJSONObject(i)
|
||||
val keyValue = item.getString(key)
|
||||
destination[keyValue] = item
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
fun JSONArray?.isNullOrEmpty(): Boolean {
|
||||
contract {
|
||||
returns(false) implies (this@isNullOrEmpty != null)
|
||||
}
|
||||
|
||||
return this == null || this.length() == 0
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
fun LocaleListCompat.toList(): List<Locale> {
|
||||
val list = ArrayList<Locale>(size())
|
||||
@@ -26,4 +25,8 @@ inline fun <R, C : MutableCollection<in R>> LocaleListCompat.mapTo(
|
||||
|
||||
inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> {
|
||||
return mapTo(ArrayList(size()), block)
|
||||
}
|
||||
|
||||
inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
|
||||
return mapTo(LinkedHashSet(size()), block)
|
||||
}
|
||||
@@ -133,6 +133,8 @@ fun View.resetTransformations() {
|
||||
translationZ = 0f
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
rotationX = 0f
|
||||
rotationY = 0f
|
||||
}
|
||||
|
||||
inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): JSONObject = array.getJSONObject(index++)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): String = array.getString(index++)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSONValuesIterator(
|
||||
private val jo: JSONObject,
|
||||
): Iterator<Any> {
|
||||
|
||||
private val keyIterator = jo.keys()
|
||||
|
||||
override fun hasNext(): Boolean = keyIterator.hasNext()
|
||||
|
||||
override fun next(): Any {
|
||||
val key = keyIterator.next()
|
||||
return jo.get(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.utils.progress
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
|
||||
class ImageRequestIndicatorListener(
|
||||
private val indicator: BaseProgressIndicator<*>,
|
||||
) : ImageRequest.Listener {
|
||||
|
||||
override fun onCancel(request: ImageRequest) = indicator.hide()
|
||||
|
||||
override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide()
|
||||
|
||||
override fun onStart(request: ImageRequest) = indicator.show()
|
||||
|
||||
override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide()
|
||||
}
|
||||
5
app/src/main/res/drawable/ic_expand_collapse.xml
Normal file
5
app/src/main/res/drawable/ic_expand_collapse.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_expand_less" android:state_checked="true" />
|
||||
<item android:drawable="@drawable/ic_expand_more" android:state_checked="false" />
|
||||
</selector>
|
||||
5
app/src/main/res/drawable/ic_expand_less.xml
Normal file
5
app/src/main/res/drawable/ic_expand_less.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"/>
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_expand_more.xml
Normal file
11
app/src/main/res/drawable/ic_expand_more.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
|
||||
</vector>
|
||||
26
app/src/main/res/layout/activity_image.xml
Normal file
26
app/src/main/res/layout/activity_image.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
android:id="@+id/ssiv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:transitionName="cover" />
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
style="@style/Widget.Kotatsu.Toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<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" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -11,7 +11,10 @@
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/inputLayout"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
app:boxBackgroundMode="filled"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:hintEnabled="false"
|
||||
app:expandedHintEnabled="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
@@ -21,7 +24,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
tools:hint="@tools:sample/lorem[2]" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:scaleType="centerCrop"
|
||||
android:transitionName="cover"
|
||||
tools:background="@tools:sample/backgrounds/scenic"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
|
||||
15
app/src/main/res/layout/item_expandable.xml
Normal file
15
app/src/main/res/layout/item_expandable.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<CheckedTextView
|
||||
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="?android:listPreferredItemHeightSmall"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:drawablePadding="12dp"
|
||||
android:gravity="center_vertical|start"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
app:drawableEndCompat="@drawable/ic_expand_collapse"
|
||||
app:drawableTint="?android:textColorPrimary"
|
||||
tools:text="@tools:sample/full_names" />
|
||||
@@ -19,6 +19,17 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_number"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
tools:text="5" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_error"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<ImageView
|
||||
android:id="@+id/imageView_handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="?listPreferredItemPaddingStart"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="?listPreferredItemPaddingStart"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_reorder_handle" />
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
<ImageView
|
||||
android:id="@+id/imageView_config"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/settings"
|
||||
android:padding="?listPreferredItemPaddingEnd"
|
||||
android:paddingHorizontal="?listPreferredItemPaddingEnd"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_settings" />
|
||||
|
||||
|
||||
@@ -244,4 +244,6 @@
|
||||
<string name="state_ongoing">Ангоінг</string>
|
||||
<string name="date_format">Фармат даты</string>
|
||||
<string name="system_default">Па змаўчанні</string>
|
||||
<string name="exclude_nsfw_from_history">Не паказваць NSFW мангу з гісторыі</string>
|
||||
<string name="error_empty_name">Імя не можа быць пустым</string>
|
||||
</resources>
|
||||
@@ -244,4 +244,6 @@
|
||||
<string name="state_ongoing">Fortlaufend</string>
|
||||
<string name="date_format">Datumsformat</string>
|
||||
<string name="system_default">Standard</string>
|
||||
<string name="exclude_nsfw_from_history">NSFW-Manga aus dem Verlauf ausschließen</string>
|
||||
<string name="error_empty_name">Der Name sollte nicht leer sein</string>
|
||||
</resources>
|
||||
@@ -86,7 +86,7 @@
|
||||
<string name="_continue">Continuar</string>
|
||||
<string name="warning">Advertencia</string>
|
||||
<string name="network_consumption_warning">Esta operación puede consumir mucho tráfico de red</string>
|
||||
<string name="dont_ask_again">No volver a preguntar</string>
|
||||
<string name="dont_ask_again">No vuelvas a preguntar</string>
|
||||
<string name="cancelling_">Cancelar…</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="clear_thumbs_cache">Borrar la caché de miniaturas</string>
|
||||
@@ -241,4 +241,9 @@
|
||||
<string name="about_feedback_4pda">Tema sobre 4PDA</string>
|
||||
<string name="date_format">Formato de la fecha</string>
|
||||
<string name="system_default">Por defecto</string>
|
||||
<string name="tracker_warning">Algunos fabricantes pueden cambiar el comportamiento del sistema, lo que podría interrumpir las tareas en segundo plano.</string>
|
||||
<string name="error_empty_name">Nombre no debe estar vacío</string>
|
||||
<string name="auth_not_supported_by">Autorización en %s no es compatible</string>
|
||||
<string name="text_clear_cookies_prompt">Se cerrará la sesión de todas las fuentes en las que esté autorizado</string>
|
||||
<string name="exclude_nsfw_from_history">Excluye manga NSFW del historial</string>
|
||||
</resources>
|
||||
@@ -244,4 +244,6 @@
|
||||
<string name="state_ongoing">Jatkuva</string>
|
||||
<string name="date_format">Päivämäärän muoto</string>
|
||||
<string name="system_default">Oletus</string>
|
||||
<string name="error_empty_name">Nimi ei saa olla tyhjä</string>
|
||||
<string name="exclude_nsfw_from_history">Sulje NSFW-mangat pois historiasta</string>
|
||||
</resources>
|
||||
@@ -244,4 +244,6 @@
|
||||
<string name="state_ongoing">En cours</string>
|
||||
<string name="date_format">Format de la date</string>
|
||||
<string name="system_default">Par défaut</string>
|
||||
<string name="exclude_nsfw_from_history">Exclure les mangas osés de l\'historique</string>
|
||||
<string name="error_empty_name">Le nom ne doit pas être vide</string>
|
||||
</resources>
|
||||
@@ -244,4 +244,6 @@
|
||||
<string name="state_ongoing">In corso</string>
|
||||
<string name="system_default">Predefinito</string>
|
||||
<string name="date_format">Formato della data</string>
|
||||
<string name="exclude_nsfw_from_history">Escludi i manga NSFW dalla storia</string>
|
||||
<string name="error_empty_name">Il nome non dovrebbe essere vuoto</string>
|
||||
</resources>
|
||||
@@ -223,4 +223,28 @@
|
||||
<string name="chapter_is_missing">Kapittel mangler</string>
|
||||
<string name="text_downloads_holder">Det er ingen aktive nedlastinger</string>
|
||||
<string name="queued">I kø</string>
|
||||
<string name="state_finished">Fullført</string>
|
||||
<string name="state_ongoing">Pågående</string>
|
||||
<string name="about_app_translation_summary">Oversett dette programmet</string>
|
||||
<string name="about_app_translation">Oversettelse</string>
|
||||
<string name="about_author">Utvikler</string>
|
||||
<string name="about_feedback">Tilbakemelding</string>
|
||||
<string name="about_feedback_4pda">Emne på 4PDA</string>
|
||||
<string name="about_support_developer">Støtt utvikleren</string>
|
||||
<string name="about_support_developer_summary">Hvis du liker programmet kan du kronerulle det på Yoomoney (tidligere Yandex.Money)</string>
|
||||
<string name="about_gratitudes">Takk rettes til</string>
|
||||
<string name="about_gratitudes_summary">Folk som gjorde Kotatsu enda bedre.</string>
|
||||
<string name="about_copyright_and_licenses">Opphavsrett og lisenser</string>
|
||||
<string name="about_license">Lisens</string>
|
||||
<string name="auth_complete">Identitetsbekreftelse fullført</string>
|
||||
<string name="auth_not_supported_by">Identitetsbekreftelse på %s støttes ikke</string>
|
||||
<string name="text_clear_cookies_prompt">Du vil bli utlogget fra alle kilder du pålogget i</string>
|
||||
<string name="genres">Sjangere</string>
|
||||
<string name="exclude_nsfw_from_history">Utelat NSFW-manga fra historikk</string>
|
||||
<string name="date_format">Datoformat</string>
|
||||
<string name="system_default">Forvalg</string>
|
||||
<string name="error_empty_name">Navn må angis</string>
|
||||
<string name="available_sources">Tilgjengelige kilder</string>
|
||||
<string name="show_pages_numbers">Vis sidenummerering</string>
|
||||
<string name="enabled_sources">Påskrudde kilder</string>
|
||||
</resources>
|
||||
@@ -243,4 +243,9 @@
|
||||
<string name="state_ongoing">Онгоинг</string>
|
||||
<string name="date_format">Формат даты</string>
|
||||
<string name="system_default">По умолчанию</string>
|
||||
<string name="exclude_nsfw_from_history">Исключить NSFW мангу из истории</string>
|
||||
<string name="error_empty_name">Имя не может быть пустым</string>
|
||||
<string name="show_pages_numbers">Показывать номера страниц</string>
|
||||
<string name="enabled_sources">Включенные источники</string>
|
||||
<string name="available_sources">Доступные источники</string>
|
||||
</resources>
|
||||
@@ -87,7 +87,7 @@
|
||||
<string name="_continue">Continue</string>
|
||||
<string name="warning">Warning</string>
|
||||
<string name="network_consumption_warning">This operation may consume a lot of network traffic</string>
|
||||
<string name="dont_ask_again">Don`t ask again</string>
|
||||
<string name="dont_ask_again">Don\'t ask again</string>
|
||||
<string name="cancelling_">Cancelling…</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="clear_thumbs_cache">Clear thumbnails cache</string>
|
||||
@@ -216,32 +216,37 @@
|
||||
<string name="text_clear_search_history_prompt">Do you really want to remove all recent search queries? This action cannot be undone.</string>
|
||||
<string name="other">Other</string>
|
||||
<string name="languages">Languages</string>
|
||||
<string name="welcome">Welcome</string>
|
||||
<string name="description">Description</string>
|
||||
<string name="backup_saved">Backup saved successfully</string>
|
||||
<string name="tracker_warning">Some manufacturers can change the system behavior, which may breaks background tasks.</string>
|
||||
<string name="read_more">Read more</string>
|
||||
<string name="queued">Queued</string>
|
||||
<string name="text_downloads_holder">There are currently no active downloads</string>
|
||||
<string name="chapter_is_missing_text">This chapter is missing on your device. Download or read it online.</string>
|
||||
<string name="chapter_is_missing">Chapter is missing</string>
|
||||
<string name="about_app_translation_summary">Translate this app</string>
|
||||
<string name="about_app_translation">Translation</string>
|
||||
<string name="about_author">Author</string>
|
||||
<string name="about_feedback">Feedback</string>
|
||||
<string name="about_feedback_4pda">Topic on 4PDA</string>
|
||||
<string name="about_support_developer">Support the developer</string>
|
||||
<string name="about_support_developer_summary">If you like this app, you can help financially through Yoomoney (ex. Yandex.Money)</string>
|
||||
<string name="about_gratitudes">Gratitudes</string>
|
||||
<string name="about_gratitudes_summary">These people make Kotatsu become better!</string>
|
||||
<string name="about_copyright_and_licenses">Copyright & Licenses</string>
|
||||
<string name="about_license">License</string>
|
||||
<string name="auth_complete">Authorization complete</string>
|
||||
<string name="auth_not_supported_by">Authorization on %s is not supported</string>
|
||||
<string name="text_clear_cookies_prompt">You will be logged out from all sources that you are authorized in</string>
|
||||
<string name="welcome">Welcome</string>
|
||||
<string name="description">Description</string>
|
||||
<string name="backup_saved">Backup saved successfully</string>
|
||||
<string name="tracker_warning">Some manufacturers can change the system behavior, which may breaks background tasks.</string>
|
||||
<string name="read_more">Read more</string>
|
||||
<string name="queued">Queued</string>
|
||||
<string name="text_downloads_holder">There are currently no active downloads</string>
|
||||
<string name="chapter_is_missing_text">This chapter is missing on your device. Download or read it online.</string>
|
||||
<string name="chapter_is_missing">Chapter is missing</string>
|
||||
<string name="about_app_translation_summary">Translate this app</string>
|
||||
<string name="about_app_translation">Translation</string>
|
||||
<string name="about_author">Author</string>
|
||||
<string name="about_feedback">Feedback</string>
|
||||
<string name="about_feedback_4pda">Topic on 4PDA</string>
|
||||
<string name="about_support_developer">Support the developer</string>
|
||||
<string name="about_support_developer_summary">If you like this app, you can help financially through Yoomoney (ex. Yandex.Money)</string>
|
||||
<string name="about_gratitudes">Gratitudes</string>
|
||||
<string name="about_gratitudes_summary">These people make Kotatsu become better!</string>
|
||||
<string name="about_copyright_and_licenses">Copyright and Licenses</string>
|
||||
<string name="about_license">License</string>
|
||||
<string name="auth_complete">Authorization complete</string>
|
||||
<string name="auth_not_supported_by">Authorization on %s is not supported</string>
|
||||
<string name="text_clear_cookies_prompt">You will be logged out from all sources that you are authorized in</string>
|
||||
<string name="genres">Genres</string>
|
||||
<string name="state_finished">Finished</string>
|
||||
<string name="state_ongoing">Ongoing</string>
|
||||
<string name="date_format">Date format</string>
|
||||
<string name="system_default">Default</string>
|
||||
<string name="exclude_nsfw_from_history">Exclude NSFW manga from history</string>
|
||||
<string name="error_empty_name">Name should not be empty</string>
|
||||
<string name="show_pages_numbers">Show pages numbers</string>
|
||||
<string name="enabled_sources">Enabled sources</string>
|
||||
<string name="available_sources">Available sources</string>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user