Merge branch 'devel' into feature/page-preload

pull/118/head
Koitharu 4 years ago
commit 2b8c713169
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -87,9 +87,9 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
implementation 'androidx.room:room-runtime:2.4.1' implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.1' implementation 'androidx.room:room-ktx:2.4.2'
kapt 'androidx.room:room-compiler:2.4.1' kapt 'androidx.room:room-compiler:2.4.2'
implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.squareup.okio:okio:3.0.0' implementation 'com.squareup.okio:okio:3.0.0'
@ -115,6 +115,6 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.room:room-testing:2.4.1' androidTestImplementation 'androidx.room:room-testing:2.4.2'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
} }

@ -64,6 +64,7 @@
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"

@ -27,6 +27,7 @@ import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule import org.koitharu.kotatsu.widget.appWidgetModule
@ -67,6 +68,7 @@ class KotatsuApp : Application() {
settingsModule, settingsModule,
readerModule, readerModule,
appWidgetModule, appWidgetModule,
suggestionsModule,
) )
} }
} }

@ -6,7 +6,10 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) { class MangaDataRepository(private val db: MangaDatabase) {
@ -45,4 +48,10 @@ class MangaDataRepository(private val db: MangaDatabase) {
db.mangaDao.upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
} }
} }
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet {
it.toMangaTag()
}
}
} }

@ -33,7 +33,7 @@ abstract class BaseViewModel : ViewModel() {
} }
} }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> protected fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
throwable.printStackTrace() throwable.printStackTrace()
} }

@ -1,41 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isGone
import com.google.android.material.R
import com.google.android.material.appbar.MaterialToolbar
import java.lang.reflect.Field
class AnimatedToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.toolbarStyle,
) : MaterialToolbar(context, attrs, defStyleAttr) {
private var navButtonView: View? = null
get() {
if (field == null) {
runCatching {
field = navButtonViewField?.get(this) as? View
}
}
return field
}
override fun setNavigationIcon(icon: Drawable?) {
super.setNavigationIcon(icon)
navButtonView?.isGone = (icon == null)
}
private companion object {
val navButtonViewField: Field? = runCatching {
Toolbar::class.java.getDeclaredField("mNavButtonView")
.also { it.isAccessible = true }
}.getOrNull()
}
}

@ -29,6 +29,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
javaScriptEnabled = true javaScriptEnabled = true
} }
binding.webView.webViewClient = BrowserClient(this) binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
if (savedInstanceState != null) {
return
}
val url = intent?.dataString val url = intent?.dataString
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
finishAfterTransition() finishAfterTransition()
@ -41,6 +45,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
binding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
binding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_browser, menu) menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
@ -82,6 +96,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
binding.webView.onResume() binding.webView.onResume()
} }
override fun onDestroy() {
super.onDestroy()
binding.webView.destroy()
}
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading binding.progressBar.isVisible = isLoading
} }

@ -0,0 +1,31 @@
package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
private const val PROGRESS_MAX = 100
class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>,
) : WebChromeClient() {
init {
progressIndicator.max = PROGRESS_MAX
}
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (!progressIndicator.isVisible) {
return
}
if (newProgress in 1 until PROGRESS_MAX) {
progressIndicator.setIndeterminateCompat(false)
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else {
progressIndicator.setIndeterminateCompat(true)
}
}
}

@ -10,6 +10,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
@Database( @Database(
entities = [ entities = [
@ -35,4 +37,6 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val tracksDao: TracksDao abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
} }

@ -6,8 +6,8 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao @Dao
abstract class TagsDao { abstract class TagsDao {
@Query("SELECT * FROM tags") @Query("SELECT * FROM tags WHERE source = :source")
abstract suspend fun getAllTags(): List<TagEntity> abstract suspend fun findTags(source: String): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(tag: TagEntity): Long abstract suspend fun insert(tag: TagEntity): Long

@ -6,6 +6,7 @@ import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.toTitleCase
@Entity(tableName = "tags") @Entity(tableName = "tags")
class TagEntity( class TagEntity(
@ -18,7 +19,7 @@ class TagEntity(
fun toMangaTag() = MangaTag( fun toMangaTag() = MangaTag(
key = this.key, key = this.key,
title = this.title, title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source) source = MangaSource.valueOf(this.source)
) )

@ -1,10 +0,0 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class MangaFilter(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable

@ -4,7 +4,6 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
abstract class RemoteMangaRepository( abstract class RemoteMangaRepository(
@ -20,8 +19,6 @@ abstract class RemoteMangaRepository(
val title: String val title: String
get() = source.title get() = source.title
override val sortOrders: Set<SortOrder> get() = emptySet()
override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain() override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain()
override suspend fun getTags(): Set<MangaTag> = emptySet() override suspend fun getTags(): Set<MangaTag> = emptySet()

@ -237,7 +237,6 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
when { when {
c == '-' -> { c == '-' -> {
builder.setCharAt(i, ' ') builder.setCharAt(i, ' ')
capitalize = true
} }
capitalize -> { capitalize -> {
builder.setCharAt(i, c.uppercaseChar()) builder.setCharAt(i, c.uppercaseChar())

@ -61,7 +61,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
tags = runCatching { tags = runCatching {
row.selectFirst("div.genre")?.select("a")?.mapToSet { row.selectFirst("div.genre")?.select("a")?.mapToSet {
MangaTag( MangaTag(
title = it.text(), title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/').urlEncoded(), key = it.attr("href").substringAfterLast('/').urlEncoded(),
source = source source = source
) )
@ -136,7 +136,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
return root.select("li.sidetag").mapToSet { li -> return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last() ?: throw ParseException("a is null") val a = li.children().last() ?: throw ParseException("a is null")
MangaTag( MangaTag(
title = a.text().toCamelCase(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )

@ -85,7 +85,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
tags = json.getJSONArray("genres").mapToSet { tags = json.getJSONArray("genres").mapToSet {
MangaTag( MangaTag(
key = it.getString("text"), key = it.getString("text"),
title = it.getString("russian"), title = it.getString("russian").toTitleCase(),
source = manga.source source = manga.source
) )
}, },
@ -133,7 +133,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaTag( MangaTag(
source = source, source = source,
key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(), key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
title = it.selectFirst("label")?.text() ?: parseFailed() title = it.selectFirst("label")?.text()?.toTitleCase() ?: parseFailed()
) )
} }
} }

@ -17,6 +17,8 @@ class ExHentaiRepository(
override val source = MangaSource.EXHENTAI override val source = MangaSource.EXHENTAI
override val sortOrders: Set<SortOrder> = emptySet()
override val defaultDomain: String override val defaultDomain: String
get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED
@ -85,7 +87,7 @@ class ExHentaiRepository(
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found") val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div -> val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag( MangaTag(
title = div.text(), title = div.text().toTitleCase(),
key = tagIdByClass(div.classNames()) ?: return@let null, key = tagIdByClass(div.classNames()) ?: return@let null,
source = source, source = source,
) )
@ -181,7 +183,7 @@ class ExHentaiRepository(
val id = div.id().substringAfterLast('_').toIntOrNull() val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null ?: return@mapNotNullToSet null
MangaTag( MangaTag(
title = div.text(), title = div.text().toTitleCase(),
key = id.toString(), key = id.toString(),
source = source source = source
) )

@ -89,7 +89,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
tileInfo?.select("a.element-link") tileInfo?.select("a.element-link")
?.mapToSet { ?.mapToSet {
MangaTag( MangaTag(
title = it.text(), title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'), key = it.attr("href").substringAfterLast('/'),
source = source source = source
) )
@ -119,7 +119,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
.mapNotNull { .mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )
@ -183,7 +183,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
?.selectFirst("table.table") ?: parseFailed("Cannot find root") ?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a -> return root.select("a.element-link").mapToSet { a ->
MangaTag( MangaTag(
title = a.text().toCamelCase(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )

@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.parseHtml import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.toTitleCase
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
@ -36,7 +37,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet { tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: parseFailed("Invalid tag") val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )

@ -94,7 +94,8 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
MangaTag( MangaTag(
title = tag.getJSONObject("attributes") title = tag.getJSONObject("attributes")
.getJSONObject("name") .getJSONObject("name")
.firstStringValue(), .firstStringValue()
.toTitleCase(),
key = tag.getString("id"), key = tag.getString("id"),
source = source, source = source,
) )
@ -194,7 +195,7 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
.getJSONArray("data") .getJSONArray("data")
return tags.mapToSet { jo -> return tags.mapToSet { jo ->
MangaTag( MangaTag(
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(), title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(),
key = jo.getString("id"), key = jo.getString("id"),
source = source, source = source,
) )

@ -139,7 +139,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
tags = info?.selectFirst("div.media-tags") tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapToSet { a -> ?.select("a.media-tag-item")?.mapToSet { a ->
MangaTag( MangaTag(
title = a.text().toCamelCase(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('='), key = a.attr("href").substringAfterLast('='),
source = source source = source
) )
@ -203,7 +203,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag( result += MangaTag(
source = source, source = source,
key = x.getInt("id").toString(), key = x.getInt("id").toString(),
title = x.getString("name").toCamelCase() title = x.getString("name").toTitleCase(),
) )
} }
return result return result

@ -91,7 +91,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
.mapNotNull { .mapNotNull {
val a = it.selectFirst("a") ?: return@mapNotNull null val a = it.selectFirst("a") ?: return@mapNotNull null
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href"), key = a.attr("href"),
source = source source = source
) )
@ -144,7 +144,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
return root.mapToSet { p -> return root.mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null") val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag( MangaTag(
title = a.text().toCamelCase(), title = a.text().toTitleCase(),
key = a.attr("href"), key = a.attr("href"),
source = source source = source
) )

@ -80,7 +80,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}, },
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x -> tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag( MangaTag(
title = x.attr("title"), title = x.attr("title").toTitleCase(),
key = x.attr("href").parseTagKey() ?: return@tags null, key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
@ -104,7 +104,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
x.selectFirst("b")?.ownText() == "Genre(s):" x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a -> }?.select("a")?.mapNotNull { a ->
MangaTag( MangaTag(
title = a.attr("title"), title = a.attr("title").toTitleCase(),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null, key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
@ -172,7 +172,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
MangaTag( MangaTag(
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
key = key, key = key,
title = a.text() title = a.text().toTitleCase()
) )
} }
} }

@ -62,7 +62,7 @@ class MangareadRepository(
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a -> tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
}.orEmpty(), }.orEmpty(),
@ -91,7 +91,7 @@ class MangareadRepository(
} }
MangaTag( MangaTag(
key = href, key = href,
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
} }
@ -113,7 +113,7 @@ class MangareadRepository(
?.mapNotNullToSet { a -> ?.mapNotNullToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
} ?: manga.tags, } ?: manga.tags,

@ -94,7 +94,7 @@ abstract class NineMangaRepository(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first() tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a -> ?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href").substringBetween("/", "."), key = a.attr("href").substringBetween("/", "."),
source = source, source = source,
) )

@ -73,7 +73,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
author = null, author = null,
tags = jo.optJSONArray("genres")?.mapToSet { g -> tags = jo.optJSONArray("genres")?.mapToSet { g ->
MangaTag( MangaTag(
title = g.getString("name"), title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(), key = g.getInt("id").toString(),
source = MangaSource.REMANGA source = MangaSource.REMANGA
) )
@ -109,7 +109,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}, },
tags = content.getJSONArray("genres").mapToSet { g -> tags = content.getJSONArray("genres").mapToSet { g ->
MangaTag( MangaTag(
title = g.getString("name"), title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(), key = g.getInt("id").toString(),
source = MangaSource.REMANGA source = MangaSource.REMANGA
) )
@ -175,7 +175,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
.parseJson().getJSONObject("content").getJSONArray("genres") .parseJson().getJSONObject("content").getJSONArray("genres")
return content.mapToSet { jo -> return content.mapToSet { jo ->
MangaTag( MangaTag(
title = jo.getString("name"), title = jo.getString("name").toTitleCase(),
key = jo.getInt("id").toString(), key = jo.getInt("id").toString(),
source = source source = source
) )

@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs
enum class AppSection { enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
} }

@ -142,6 +142,12 @@ class AppSettings(context: Context) {
} }
} }
val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true NETWORK_ALWAYS -> true
@ -241,6 +247,8 @@ class AppSettings(context: Context) {
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload" const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.data
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao @Dao
@ -22,6 +23,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
abstract suspend fun findAllTags(): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id") @Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity? abstract suspend fun find(id: Long): HistoryEntity?

@ -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.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@ -89,4 +90,8 @@ class HistoryRepository(
db.historyDao.delete(manga.id) db.historyDao.delete(manga.id)
} }
} }
suspend fun getAllTags(): Set<MangaTag> {
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
}
} }

@ -85,7 +85,7 @@ class HistoryListViewModel(
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1) val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
if (!grouped) { if (!grouped) {
result += ListHeader(null, R.string.history) result += ListHeader(null, R.string.history, null)
} }
for ((manga, history) in list) { for ((manga, history) in list) {
if (grouped) { if (grouped) {

@ -4,13 +4,9 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -30,8 +26,6 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
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.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainActivity
@ -43,7 +37,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
SwipeRefreshLayout.OnRefreshListener { SwipeRefreshLayout.OnRefreshListener {
private var listAdapter: MangaListAdapter? = null private var listAdapter: MangaListAdapter? = null
private var filterAdapter: FilterAdapter2? = null
private var paginationListener: PaginationScrollListener? = null private var paginationListener: PaginationScrollListener? = null
private val spanResolver = MangaListSpanResolver() private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup() private val spanSizeLookup = SpanSizeLookup()
@ -51,7 +44,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
} }
open val isSwipeRefreshEnabled = true open val isSwipeRefreshEnabled = true
private var drawer: DrawerLayout? = null
protected abstract val viewModel: MangaListViewModel protected abstract val viewModel: MangaListViewModel
@ -67,16 +59,14 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
listAdapter = MangaListAdapter( listAdapter = MangaListAdapter(
coil = get(), coil = get(),
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
clickListener = this, clickListener = this,
onRetryClick = ::resolveException, onRetryClick = ::resolveException,
onTagRemoveClick = viewModel::onRemoveFilterTag onTagRemoveClick = viewModel::onRemoveFilterTag,
onFilterClickListener = this::onFilterClick,
) )
filterAdapter = FilterAdapter2(viewModel)
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
@ -89,17 +79,12 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
setOnRefreshListener(this@MangaListFragment) setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled isEnabled = isSwipeRefreshEnabled
} }
with(binding.recyclerViewFilter) {
setHasFixedSize(true)
adapter = filterAdapter
}
(parentFragment as? RecycledViewPoolHolder)?.let { (parentFragment as? RecycledViewPoolHolder)?.let {
binding.recyclerView.setRecycledViewPool(it.recycledViewPool) binding.recyclerView.setRecycledViewPool(it.recycledViewPool)
} }
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.filter.observe(viewLifecycleOwner, ::onInitFilter)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
@ -107,9 +92,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
} }
override fun onDestroyView() { override fun onDestroyView() {
drawer = null
listAdapter = null listAdapter = null
filterAdapter = null
paginationListener = null paginationListener = null
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
super.onDestroyView() super.onDestroyView()
@ -125,19 +108,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
ListModeSelectDialog.show(childFragmentManager) ListModeSelectDialog.show(childFragmentManager)
true true
} }
R.id.action_filter -> {
drawer?.toggleDrawer(GravityCompat.END)
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.action_filter).isVisible = drawer != null &&
drawer?.getDrawerLockMode(GravityCompat.END) != DrawerLayout.LOCK_MODE_LOCKED_CLOSED
super.onPrepareOptionsMenu(menu)
}
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item)) startActivity(DetailsActivity.newIntent(context ?: return, item))
} }
@ -200,27 +173,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
} }
} }
protected fun onInitFilter(filter: List<FilterItem>) {
filterAdapter?.items = filter
drawer?.setDrawerLockMode(
if (filter.isEmpty()) {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
} else {
DrawerLayout.LOCK_MODE_UNLOCKED
}
) ?: binding.dividerFilter?.let {
it.isGone = filter.isEmpty()
binding.recyclerViewFilter.isVisible = it.isVisible
}
activity?.invalidateOptionsMenu()
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerViewFilter.updatePadding(
top = headerHeight,
bottom = insets.bottom
)
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right
@ -238,6 +192,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
} }
} }
protected open fun onFilterClick() = Unit
private fun onGridScaleChanged(scale: Float) { private fun onGridScaleChanged(scale: Float) {
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
spanResolver.setGridSize(scale, binding.recyclerView) spanResolver.setGridSize(scale, binding.recyclerView)

@ -1,32 +1,22 @@
package org.koitharu.kotatsu.list.ui package org.koitharu.kotatsu.list.ui
import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.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.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel(), OnFilterChangedListener { ) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>> abstract val content: LiveData<List<ListModel>>
val filter = MutableLiveData<List<FilterItem>>()
val listMode = MutableLiveData<ListMode>() val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe() val gridScale = settings.observe()
.filter { it == AppSettings.KEY_GRID_SIZE } .filter { it == AppSettings.KEY_GRID_SIZE }
@ -35,6 +25,8 @@ abstract class MangaListViewModel(
settings.gridSize / 100f settings.gridSize / 100f
} }
open fun onRemoveFilterTag(tag: MangaTag) = Unit
protected fun createListModeFlow() = settings.observe() protected fun createListModeFlow() = settings.observe()
.filter { it == AppSettings.KEY_LIST_MODE } .filter { it == AppSettings.KEY_LIST_MODE }
.map { settings.listMode } .map { settings.listMode }
@ -46,63 +38,6 @@ abstract class MangaListViewModel(
} }
} }
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() abstract fun onRefresh()
abstract fun onRetry() abstract fun onRetry()

@ -2,11 +2,16 @@ package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemHeaderWithFilterBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header) { fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(
layout = R.layout.item_header,
on = { item, _, _ -> item is ListHeader && item.sortOrder == null },
) {
bind { bind {
val textView = (itemView as TextView) val textView = (itemView as TextView)
@ -16,4 +21,25 @@ fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header
textView.setText(item.textRes) textView.setText(item.textRes)
} }
} }
}
fun listHeaderWithFilterAD(
onFilterClickListener: () -> Unit,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderWithFilterBinding>(
viewBinding = { inflater, parent -> ItemHeaderWithFilterBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ListHeader && item.sortOrder != null },
) {
binding.textViewFilter.setOnClickListener {
onFilterClickListener()
}
bind {
if (item.text != null) {
binding.textViewTitle.text = item.text
} else {
binding.textViewTitle.setText(item.textRes)
}
binding.textViewFilter.setText(requireNotNull(item.sortOrder).titleRes)
}
} }

@ -20,6 +20,7 @@ class MangaListAdapter(
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
onRetryClick: (Throwable) -> Unit, onRetryClick: (Throwable) -> Unit,
onTagRemoveClick: (MangaTag) -> Unit, onTagRemoveClick: (MangaTag) -> Unit,
onFilterClickListener: () -> Unit,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
@ -41,6 +42,7 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD()) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) .addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick)) .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
.addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(onFilterClickListener))
} }
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() { private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@ -79,5 +81,6 @@ class MangaListAdapter(
const val ITEM_TYPE_EMPTY = 8 const val ITEM_TYPE_EMPTY = 8
const val ITEM_TYPE_HEADER = 9 const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10 const val ITEM_TYPE_FILTER = 10
const val ITEM_TYPE_HEADER_FILTER = 11
} }
} }

@ -2,11 +2,13 @@ package org.koitharu.kotatsu.list.ui.filter
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter2( class FilterAdapter(
listener: OnFilterChangedListener, listener: OnFilterChangedListener,
) : AsyncListDifferDelegationAdapter<FilterItem>( ) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(), FilterDiffCallback(),
filterSortDelegate(listener), filterSortDelegate(listener),
filterTagDelegate(listener), filterTagDelegate(listener),
filterHeaderDelegate(), filterHeaderDelegate(),
filterLoadingDelegate(),
filterErrorDelegate(),
) )

@ -1,6 +1,9 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
@ -44,4 +47,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
bind { bind {
binding.root.setText(item.titleResId) binding.root.setText(item.titleResId)
} }
}
fun filterLoadingDelegate() = adapterDelegate<FilterItem.Loading, FilterItem>(R.layout.item_loading_footer) {}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, FilterItem>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
} }

@ -0,0 +1,84 @@
package org.koitharu.kotatsu.list.ui.filter
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.utils.ext.withArgs
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>() {
private val viewModel by sharedViewModel<FilterViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) }
) {
parametersOf(
requireArguments().getParcelable<MangaSource>(ARG_SOURCE),
requireArguments().getParcelable<FilterState>(ARG_STATE),
)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener { dismiss() }
if (!resources.getBoolean(R.bool.is_tablet)) {
binding.toolbar.navigationIcon = null
}
val adapter = FilterAdapter(viewModel)
binding.recyclerView.adapter = adapter
viewModel.filter.observe(viewLifecycleOwner, adapter::setItems)
viewModel.result.observe(viewLifecycleOwner) {
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it))
}
}
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
} else {
binding.toolbar.navigationIcon = null
}
}
}
)
}
companion object {
const val REQUEST_KEY = "filter"
const val ARG_STATE = "state"
private const val TAG = "FilterBottomSheet"
private const val ARG_SOURCE = "source"
fun show(
fm: FragmentManager,
source: MangaSource,
state: FilterState,
) = FilterBottomSheet().withArgs(2) {
putParcelable(ARG_SOURCE, source)
putParcelable(ARG_STATE, state)
}.show(fm, TAG)
}
}

@ -6,6 +6,7 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when { return when {
oldItem === newItem -> true
oldItem.javaClass != newItem.javaClass -> false oldItem.javaClass != newItem.javaClass -> false
oldItem is FilterItem.Header && newItem is FilterItem.Header -> { oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.titleResId == newItem.titleResId oldItem.titleResId == newItem.titleResId
@ -16,13 +17,18 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.order == newItem.order oldItem.order == newItem.order
} }
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
oldItem.textResId == newItem.textResId
}
else -> false else -> false
} }
} }
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when { return when {
oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true oldItem is FilterItem.Header && newItem is FilterItem.Header -> true
oldItem is FilterItem.Error && newItem is FilterItem.Error -> true
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked == newItem.isChecked oldItem.isChecked == newItem.isChecked
} }

@ -19,4 +19,10 @@ sealed interface FilterItem {
val tag: MangaTag, val tag: MangaTag,
val isChecked: Boolean, val isChecked: Boolean,
) : FilterItem ) : FilterItem
object Loading : FilterItem
class Error(
@StringRes val textResId: Int,
) : FilterItem
} }

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.list.ui.filter
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
@Parcelize
class FilterState(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable

@ -0,0 +1,114 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import java.util.*
class FilterViewModel(
private val repository: RemoteMangaRepository,
dataRepository: MangaDataRepository,
state: FilterState,
) : BaseViewModel(), OnFilterChangedListener {
val filter = MutableLiveData<List<FilterItem>>()
val result = MutableLiveData<FilterState>()
private var job: Job? = null
private var selectedSortOrder: SortOrder? = state.sortOrder
private val selectedTags = HashSet(state.tags)
private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) {
dataRepository.findTags(repository.source)
}
private var availableTagsDeferred = loadTagsAsync()
init {
showFilter()
}
override fun onSortItemClick(item: FilterItem.Sort) {
selectedSortOrder = item.order
updateFilters()
}
override fun onTagItemClick(item: FilterItem.Tag) {
val isModified = if (item.isChecked) {
selectedTags.remove(item.tag)
} else {
selectedTags.add(item.tag)
}
if (isModified) {
updateFilters()
}
}
private fun updateFilters() {
val previousJob = job
job = launchJob(Dispatchers.Default) {
previousJob?.cancelAndJoin()
val tags = tryLoadTags()
val localTags = localTagsDeferred.await()
val sortOrders = repository.sortOrders
val list = ArrayList<FilterItem>(sortOrders.size + (tags?.size ?: 1) + 2)
list.add(FilterItem.Header(R.string.sort_order))
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
}
if (tags == null || tags.isNotEmpty() || selectedTags.isNotEmpty()) {
list.add(FilterItem.Header(R.string.genres))
val mappedTags = TreeSet<FilterItem.Tag>(compareBy({ !it.isChecked }, { it.tag.title }))
localTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
tags?.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
selectedTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = true) }
list.addAll(mappedTags)
if (tags == null) {
list.add(FilterItem.Error(R.string.filter_load_error))
}
}
ensureActive()
filter.postValue(list)
}
result.value = FilterState(selectedSortOrder, selectedTags)
}
private fun showFilter() {
job = launchJob(Dispatchers.Default) {
val sortOrders = repository.sortOrders
val list = ArrayList<FilterItem>(sortOrders.size + selectedTags.size + 3)
list.add(FilterItem.Header(R.string.sort_order))
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
}
if (selectedTags.isNotEmpty()) {
list.add(FilterItem.Header(R.string.genres))
selectedTags.sortedBy { it.title }.mapTo(list) {
FilterItem.Tag(it, isChecked = it in selectedTags)
}
}
list.add(FilterItem.Loading)
filter.postValue(list)
updateFilters()
}
}
private suspend fun tryLoadTags(): Set<MangaTag>? {
val shouldRetryOnError = availableTagsDeferred.isCompleted
val result = availableTagsDeferred.await()
if (result == null && shouldRetryOnError) {
availableTagsDeferred = loadTagsAsync()
return availableTagsDeferred.await()
}
return result
}
private fun loadTagsAsync() = viewModelScope.async(Dispatchers.Default) {
kotlin.runCatching {
repository.getTags()
}.getOrNull()
}
}

@ -1,8 +1,10 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.SortOrder
data class ListHeader( data class ListHeader(
val text: CharSequence?, val text: CharSequence?,
@StringRes val textRes: Int, @StringRes val textRes: Int,
val sortOrder: SortOrder?,
) : ListModel ) : ListModel

@ -7,10 +7,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getLongOrDefault
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaIndex(source: String?) { class MangaIndex(source: String?) {
@ -61,7 +58,7 @@ class MangaIndex(source: String?) {
description = json.getStringOrNull("description"), description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapToSet { x -> tags = json.getJSONArray("tags").mapToSet { x ->
MangaTag( MangaTag(
title = x.getString("title"), title = x.getString("title").toTitleCase(),
key = x.getString("key"), key = x.getString("key"),
source = source source = source
) )

@ -32,7 +32,7 @@ class LocalListViewModel(
val importProgress = MutableLiveData<Progress?>(null) val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val headerModel = ListHeader(null, R.string.local_storage) private val headerModel = ListHeader(null, R.string.local_storage, null)
private var importJob: Job? = null private var importJob: Job? = null
override val content = combine( override val content = combine(

@ -49,6 +49,8 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@ -122,6 +124,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -187,6 +190,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.defaultSection = AppSection.LOCAL viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance()) setPrimaryFragment(LocalListFragment.newInstance())
} }
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> { R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance()) setPrimaryFragment(FeedFragment.newInstance())
@ -285,7 +292,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (isLoading) { if (isLoading) {
binding.fab.setImageDrawable(CircularProgressDrawable(this).also { binding.fab.setImageDrawable(CircularProgressDrawable(this).also {
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer) it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer)
it.strokeWidth = resources.resolveDp(2f) it.strokeWidth = resources.resolveDp(3.5f)
it.start() it.start()
}) })
} else { } else {
@ -303,6 +310,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
submenu.setGroupCheckable(R.id.group_remote_sources, true, true) submenu.setGroupCheckable(R.id.group_remote_sources, true, true)
} }
private fun setSuggestionsEnabled(isEnabled: Boolean) {
val item = binding.navigationView.menu.findItem(R.id.nav_suggestions) ?: return
if (!isEnabled && item.isChecked) {
binding.navigationView.setCheckedItem(R.id.nav_history)
}
item.isVisible = isEnabled
}
private fun openDefaultSection() { private fun openDefaultSection() {
when (viewModel.defaultSection) { when (viewModel.defaultSection) {
AppSection.LOCAL -> { AppSection.LOCAL -> {
@ -321,6 +336,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.navigationView.setCheckedItem(R.id.nav_feed) binding.navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance()) setPrimaryFragment(FeedFragment.newInstance())
} }
AppSection.SUGGESTIONS -> {
binding.navigationView.setCheckedItem(R.id.nav_suggestions)
setPrimaryFragment(SuggestionsFragment.newInstance())
}
} }
} }
@ -344,6 +363,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun onFirstStart() { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).checkIfNeeded() AppUpdateChecker(this@MainActivity).checkIfNeeded()
if (!get<AppSettings>().isSourcesSelected) { if (!get<AppSettings>().isSourcesSelected) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

@ -21,6 +21,12 @@ class MainViewModel(
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
var defaultSection by settings::defaultSection var defaultSection by settings::defaultSection
val isSuggestionsEnabled = settings.observe()
.filter { it == AppSettings.KEY_SUGGESTIONS }
.onStart { emit("") }
.map { settings.isSuggestionsEnabled }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val remoteSources = settings.observe() val remoteSources = settings.observe()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") } .onStart { emit("") }

@ -0,0 +1,125 @@
package org.koitharu.kotatsu.reader.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding {
return SheetChaptersBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener { dismiss() }
if (!resources.getBoolean(R.bool.is_tablet)) {
binding.toolbar.navigationIcon = null
}
binding.recyclerView.addItemDecoration(
MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)
)
val chapters = arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)
if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss()
return
}
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val dateFormat = get<AppSettings>().getDateFormat()
val items = chapters.mapIndexed { index, chapter ->
chapter.toListItem(
isCurrent = index == currentPosition,
isUnread = index > currentPosition,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
}
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
adapter.setItems(items, Scroller(binding.recyclerView, targetPosition))
} else {
adapter.items = items
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
} else {
binding.toolbar.navigationIcon = null
}
}
}
)
}
override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let {
dismiss()
it.onChapterChanged(item.chapter)
}
}
fun interface OnChapterChangeListener {
fun onChapterChanged(chapter: MangaChapter)
}
private class Scroller(private val recyclerView: RecyclerView, private val position: Int) : Runnable {
override fun run() {
val offset = recyclerView.resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) / 2
(recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(position, offset)
}
}
companion object {
private const val ARG_CHAPTERS = "chapters"
private const val ARG_CURRENT_ID = "current_id"
private const val TAG = "ChaptersBottomSheet"
fun show(
fm: FragmentManager,
chapters: List<MangaChapter>,
currentId: Long,
) = ChaptersBottomSheet().withArgs(2) {
putParcelableArrayList(ARG_CHAPTERS, chapters.asArrayList())
putLong(ARG_CURRENT_ID, currentId)
}.show(fm, TAG)
private fun <T> List<T>.asArrayList(): ArrayList<T> {
return this as? ArrayList<T> ?: ArrayList(this)
}
}
}

@ -1,99 +0,0 @@
package org.koitharu.kotatsu.reader.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.DialogChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
OnListItemClickListener<ChapterListItem> {
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogChaptersBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setTitle(R.string.chapters)
.setNegativeButton(R.string.close, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerViewChapters.addItemDecoration(
MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)
)
val chapters = arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)
if (chapters == null) {
dismissAllowingStateLoss()
return
}
val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val dateFormat = get<AppSettings>().getDateFormat()
binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply {
setItems(chapters.mapIndexed { index, chapter ->
chapter.toListItem(
isCurrent = index == currentPosition,
isUnread = index > currentPosition,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
}) {
if (currentPosition >= 0) {
with(binding.recyclerViewChapters) {
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
currentPosition,
height / 3
)
}
}
}
}
}
override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()
it.onChapterChanged(item.chapter)
}
}
fun interface OnChapterChangeListener {
fun onChapterChanged(chapter: MangaChapter)
}
companion object {
private const val TAG = "ChaptersDialog"
private const val ARG_CHAPTERS = "chapters"
private const val ARG_CURRENT_ID = "current_id"
fun show(fm: FragmentManager, chapters: List<MangaChapter>, currentId: Long = 0L) =
ChaptersDialog().withArgs(2) {
putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters))
putLong(ARG_CURRENT_ID, currentId)
}.show(fm, TAG)
}
}

@ -51,7 +51,7 @@ import org.koitharu.kotatsu.utils.anim.Motion
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(), class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
ChaptersDialog.OnChapterChangeListener, ChaptersBottomSheet.OnChapterChangeListener,
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener { ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener {
@ -152,7 +152,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this)) startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this))
} }
R.id.action_chapters -> { R.id.action_chapters -> {
ChaptersDialog.show( ChaptersBottomSheet.show(
supportFragmentManager, supportFragmentManager,
viewModel.manga?.chapters.orEmpty(), viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L viewModel.getCurrentState()?.chapterId ?: 0L

@ -14,10 +14,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
import org.koitharu.kotatsu.settings.MainSettingsFragment import org.koitharu.kotatsu.settings.*
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
import org.koitharu.kotatsu.settings.SourceSettingsFragment
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() { class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
@ -27,9 +24,11 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.commit { supportFragmentManager.commit {
replace( replace(
R.id.container, when (intent?.action) { R.id.container,
when (intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment() Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
ACTION_READER -> ReaderSettingsFragment() ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance( ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL
) )
@ -55,6 +54,8 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
private const val ACTION_READER = private const val ACTION_READER =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE = private const val ACTION_SOURCE =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source" private const val EXTRA_SOURCE = "source"
@ -63,6 +64,10 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
Intent(context, SimpleSettingsActivity::class.java) Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_READER) .setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) = fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SimpleSettingsActivity::class.java) Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SOURCE) .setAction(ACTION_SOURCE)

@ -4,12 +4,26 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.list.ui.filter.FilterViewModel
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
val remoteListModule val remoteListModule
get() = module { get() = module {
viewModel { source -> viewModel { params ->
RemoteListViewModel(get(named(source.get<MangaSource>())), get()) RemoteListViewModel(
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
settings = get(),
)
}
viewModel { params ->
FilterViewModel(
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
dataRepository = get(),
state = params.get(),
)
} }
} }

@ -1,18 +1,22 @@
package org.koitharu.kotatsu.remotelist.ui package org.koitharu.kotatsu.remotelist.ui
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.fragment.app.FragmentResultListener
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
import org.koitharu.kotatsu.utils.ext.parcelableArgument import org.koitharu.kotatsu.utils.ext.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment() { class RemoteListFragment : MangaListFragment(), FragmentResultListener {
override val viewModel by viewModel<RemoteListViewModel> { override val viewModel by viewModel<RemoteListViewModel> {
parametersOf(source) parametersOf(source)
@ -20,6 +24,11 @@ class RemoteListFragment : MangaListFragment() {
private val source by parcelableArgument<MangaSource>(ARG_SOURCE) private val source by parcelableArgument<MangaSource>(ARG_SOURCE)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
childFragmentManager.setFragmentResultListener(FilterBottomSheet.REQUEST_KEY, viewLifecycleOwner, this)
}
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadNextPage() viewModel.loadNextPage()
} }
@ -44,10 +53,26 @@ class RemoteListFragment : MangaListFragment() {
) )
true true
} }
R.id.action_filter -> {
onFilterClick()
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
override fun onFilterClick() {
FilterBottomSheet.show(childFragmentManager, source, viewModel.filter)
}
override fun onFragmentResult(requestKey: String, result: Bundle) {
when (requestKey) {
FilterBottomSheet.REQUEST_KEY -> viewModel.applyFilter(
result.getParcelable(FilterBottomSheet.ARG_STATE) ?: return
)
}
}
companion object { companion object {
private const val ARG_SOURCE = "provider" private const val ARG_SOURCE = "provider"

@ -9,38 +9,43 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.domain.AvailableFilters
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class RemoteListViewModel( class RemoteListViewModel(
private val repository: MangaRepository, private val repository: RemoteMangaRepository,
settings: AppSettings settings: AppSettings
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
var filter = FilterState(repository.sortOrders.firstOrNull(), emptySet())
private set
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null private var loadingJob: Job? = null
private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0) private val headerModel = MutableStateFlow(
ListHeader(repository.title, 0, filter.sortOrder)
)
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), createListModeFlow(),
headerModel,
listError, listError,
hasNextPage hasNextPage
) { list, mode, error, hasNext -> ) { list, mode, header, error, hasNext ->
when { when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string.empty)) list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string.empty))
else -> { else -> {
val result = ArrayList<ListModel>(list.size + 3) val result = ArrayList<ListModel>(list.size + 3)
result += headerModel result += header
createFilterModel()?.let { result.add(it) } createFilterModel()?.let { result.add(it) }
list.toUi(result, mode) list.toUi(result, mode)
when { when {
@ -54,7 +59,6 @@ class RemoteListViewModel(
init { init {
loadList(false) loadList(false)
loadFilter()
} }
override fun onRefresh() { override fun onRefresh() {
@ -65,12 +69,28 @@ class RemoteListViewModel(
loadList(append = !mangaList.value.isNullOrEmpty()) loadList(append = !mangaList.value.isNullOrEmpty())
} }
override fun onRemoveFilterTag(tag: MangaTag) {
val tags = filter.tags
if (tag !in tags) {
return
}
applyFilter(FilterState(filter.sortOrder, tags - tag))
}
fun loadNextPage() { fun loadNextPage() {
if (hasNextPage.value && listError.value == null) { if (hasNextPage.value && listError.value == null) {
loadList(append = true) loadList(append = true)
} }
} }
fun applyFilter(newFilter: FilterState) {
filter = newFilter
headerModel.value = ListHeader(repository.title, 0, newFilter.sortOrder)
mangaList.value = null
hasNextPage.value = false
loadList(false)
}
private fun loadList(append: Boolean) { private fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) { if (loadingJob?.isActive == true) {
return return
@ -80,8 +100,8 @@ class RemoteListViewModel(
listError.value = null listError.value = null
val list = repository.getList2( val list = repository.getList2(
offset = if (append) mangaList.value?.size ?: 0 else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = currentFilter.sortOrder, sortOrder = filter.sortOrder,
tags = currentFilter.tags, tags = filter.tags,
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list
@ -98,34 +118,12 @@ class RemoteListViewModel(
} }
} }
override fun onFilterChanged() {
super.onFilterChanged()
mangaList.value = null
hasNextPage.value = false
loadList(false)
}
private fun createFilterModel(): CurrentFilterModel? { private fun createFilterModel(): CurrentFilterModel? {
val tags = currentFilter.tags val tags = filter.tags
return if (tags.isEmpty()) { return if (tags.isEmpty()) {
null null
} else { } else {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
} }
} }
private fun loadFilter() {
launchJob(Dispatchers.Default) {
try {
val sorts = repository.sortOrders
val tags = repository.getTags()
availableFilters = AvailableFilters(sorts, tags)
onFilterChanged()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
}
}
}
} }

@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference import androidx.preference.TwoStatePreference
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -56,7 +56,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
entryValues = ListMode.values().names() entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name) setDefaultValueCompat(ListMode.GRID.name)
} }
findPreference<SwitchPreference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run { findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy") entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy")
@ -72,12 +72,15 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
setDefaultValueCompat("") setDefaultValueCompat("")
summary = "%s" summary = "%s"
} }
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked = findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty() !settings.appPassword.isNullOrEmpty()
settings.subscribe(this) settings.subscribe(this)
} }
@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(key)?.setSummary(R.string.restart_required) findPreference<Preference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_HIDE_TOOLBAR -> { AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required) findPreference<Preference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName() findPreference<Preference>(key)?.bindStorageName()
} }
AppSettings.KEY_APP_PASSWORD -> { AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP) findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty() ?.isChecked = !settings.appPassword.isNullOrEmpty()
} }
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
} }
} }
@ -148,7 +156,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
true true
} }
AppSettings.KEY_PROTECT_APP -> { AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? SwitchPreference ?: return false) val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) { if (pref.isChecked) {
pref.isChecked = false pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) startActivity(Intent(preference.context, ProtectSetupActivity::class.java))

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions),
SharedPreferences.OnSharedPreferenceChangeListener {
private val repository by inject<SuggestionRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings.subscribe(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_suggestions)
}
override fun onDestroy() {
super.onDestroy()
settings.unsubscribe(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_SUGGESTIONS && settings.isSuggestionsEnabled) {
onSuggestionsEnabled()
}
}
private fun onSuggestionsEnabled() {
lifecycleScope.launch {
if (repository.isEmpty()) {
SuggestionsWorker.startNow(context ?: return@launch)
}
}
}
}

@ -0,0 +1,14 @@
package org.koitharu.kotatsu.suggestions
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsViewModel
val suggestionsModule
get() = module {
factory { SuggestionRepository(get()) }
viewModel { SuggestionsViewModel(get(), get()) }
}

@ -0,0 +1,31 @@
package org.koitharu.kotatsu.suggestions.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
abstract class SuggestionDao {
@Transaction
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
abstract fun observeAll(): Flow<List<SuggestionWithManga>>
@Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: SuggestionEntity): Long
@Update
abstract suspend fun update(entity: SuggestionEntity): Int
@Query("DELETE FROM suggestions")
abstract suspend fun deleteAll()
@Transaction
open suspend fun upsert(entity: SuggestionEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
}

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.suggestions.data
import androidx.annotation.FloatRange
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "suggestions", tableName = "suggestions",
@ -19,6 +21,7 @@ import androidx.room.PrimaryKey
class SuggestionEntity( class SuggestionEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@FloatRange(from = 0.0, to = 1.0)
@ColumnInfo(name = "relevance") val relevance: Float, @ColumnInfo(name = "relevance") val relevance: Float,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
) )

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.suggestions.data
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
data class SuggestionWithManga(
@Embedded val suggestion: SuggestionEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
)

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.suggestions.domain
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.core.model.Manga
data class MangaSuggestion(
val manga: Manga,
@FloatRange(from = 0.0, to = 1.0)
val relevance: Float,
)

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.suggestions.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaDatabase
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.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class SuggestionRepository(
private val db: MangaDatabase,
) {
fun observeAll(): Flow<List<Manga>> {
return db.suggestionDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
}
}
suspend fun clear() {
db.suggestionDao.deleteAll()
}
suspend fun isEmpty(): Boolean {
return db.suggestionDao.count() == 0
}
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
db.withTransaction {
db.suggestionDao.deleteAll()
suggestions.forEach { x ->
val tags = x.manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(x.manga), tags)
db.suggestionDao.upsert(
SuggestionEntity(
mangaId = x.manga.id,
relevance = x.relevance,
createdAt = System.currentTimeMillis(),
)
)
}
}
}
}

@ -0,0 +1,57 @@
package org.koitharu.kotatsu.suggestions.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
class SuggestionsFragment : MangaListFragment() {
override val viewModel by viewModel<SuggestionsViewModel>()
override val isSwipeRefreshEnabled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_suggestions, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_update -> {
SuggestionsWorker.startNow(requireContext())
Snackbar.make(
binding.recyclerView,
R.string.feed_will_update_soon,
Snackbar.LENGTH_LONG,
).show()
true
}
R.id.action_settings -> {
startActivity(SimpleSettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onScrolledToEnd() = Unit
override fun getTitle(): CharSequence? {
return context?.getString(R.string.suggestions)
}
companion object {
fun newInstance() = SuggestionsFragment()
}
}

@ -0,0 +1,49 @@
package org.koitharu.kotatsu.suggestions.ui
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.onFirst
class SuggestionsViewModel(
repository: SuggestionRepository,
settings: AppSettings,
) : MangaListViewModel(settings) {
private val headerModel = ListHeader(null, R.string.suggestions, null)
override val content = combine(
repository.observeAll(),
createListModeFlow()
) { list, mode ->
when {
list.isEmpty() -> listOf(EmptyState(
icon = R.drawable.ic_book_cross,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_suggestion_holder,
))
else -> buildList<ListModel>(list.size + 1) {
add(headerModel)
list.toUi(this, mode)
}
}
}.onFirst {
isLoading.postValue(false)
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState)
)
override fun onRefresh() = Unit
override fun onRetry() = Unit
}

@ -0,0 +1,104 @@
package org.koitharu.kotatsu.suggestions.ui
import android.content.Context
import androidx.work.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
import java.util.concurrent.TimeUnit
import kotlin.math.pow
class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params), KoinComponent {
private val suggestionRepository by inject<SuggestionRepository>()
private val historyRepository by inject<HistoryRepository>()
private val appSettings by inject<AppSettings>()
override suspend fun doWork(): Result = try {
val count = doWorkImpl()
Result.success(workDataOf(DATA_COUNT to count))
} catch (t: Throwable) {
Result.failure()
}
private suspend fun doWorkImpl(): Int {
if (!appSettings.isSuggestionsEnabled) {
suggestionRepository.clear()
return 0
}
val rawResults = ArrayList<Manga>()
val allTags = historyRepository.getAllTags()
if (allTags.isEmpty()) {
return 0
}
val tagsBySources = allTags.groupBy { x -> x.source }
for ((source, tags) in tagsBySources) {
val repo = mangaRepositoryOf(source)
tags.flatMapTo(rawResults) { tag ->
repo.getList2(
offset = 0,
sortOrder = SortOrder.UPDATED,
tags = setOf(tag),
)
}
}
if (appSettings.isSuggestionsExcludeNsfw) {
rawResults.removeAll { it.isNsfw }
}
if (rawResults.isEmpty()) {
return 0
}
val suggestions = rawResults.distinctBy { manga ->
manga.id
}.map { manga ->
val jointTags = manga.tags intersect allTags
MangaSuggestion(
manga = manga,
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(),
)
}.sortedBy { it.relevance }.take(LIMIT)
suggestionRepository.replace(suggestions)
return suggestions.size
}
companion object {
private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot"
private const val LIMIT = 140
private const val DATA_COUNT = "count"
fun setup(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun startNow(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.build()
WorkManager.getInstance(context)
.enqueue(request)
}
}
}

@ -80,7 +80,7 @@ class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListen
Snackbar.make( Snackbar.make(
binding.recyclerView, binding.recyclerView,
R.string.feed_will_update_soon, R.string.feed_will_update_soon,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_LONG,
).show() ).show()
true true
} }

@ -236,6 +236,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_TOTAL = "total" private const val DATA_TOTAL = "total"
private const val TAG = "tracking" private const val TAG = "tracking"
private const val TAG_ONESHOT = "tracking_oneshot"
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(context: Context) { private fun createNotificationChannel(context: Context) {
@ -276,7 +277,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
.build() .build()
val request = OneTimeWorkRequestBuilder<TrackWorker>() val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints) .setConstraints(constraints)
.addTag(TAG) .addTag(TAG_ONESHOT)
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueue(request) .enqueue(request)

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z" />
</vector>

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:orientation="horizontal">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<View
android:id="@+id/divider_filter"
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="?attr/colorOutline"
android:visibility="gone"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter"
android:layout_width="240dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:windowBackground"
android:orientation="vertical"
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable"
tools:visibility="visible" />
</LinearLayout>

@ -6,22 +6,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
<WebView <WebView
android:id="@+id/webView" android:id="@+id/webView"
android:layout_width="0dp" android:layout_width="0dp"
@ -43,4 +27,20 @@
app:layout_constraintTop_toBottomOf="@id/appbar" app:layout_constraintTop_toBottomOf="@id/appbar"
tools:visibility="visible" /> tools:visibility="visible" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -22,7 +22,7 @@
android:id="@+id/appbar" android:id="@+id/appbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@android:color/transparent" android:background="@null"
android:stateListAnimator="@null"> android:stateListAnimator="@null">
<FrameLayout <FrameLayout
@ -39,7 +39,7 @@
android:id="@id/toolbar" android:id="@id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@android:color/transparent" android:background="@null"
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" android:focusableInTouchMode="true"
app:contentInsetStartWithNavigation="0dp" app:contentInsetStartWithNavigation="0dp"
@ -74,6 +74,7 @@
android:contentDescription="@string/_continue" android:contentDescription="@string/_continue"
android:src="@drawable/ic_read_fill" android:src="@drawable/ic_read_fill"
android:visibility="gone" android:visibility="gone"
app:backgroundTint="?attr/colorContainer"
app:fabSize="normal" app:fabSize="normal"
app:layout_anchor="@id/container" app:layout_anchor="@id/container"
app:layout_anchorGravity="bottom|end" app:layout_anchorGravity="bottom|end"
@ -89,7 +90,6 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:insetForeground="@android:color/transparent"
app:menu="@menu/nav_drawer" /> app:menu="@menu/nav_drawer" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>

@ -10,7 +10,6 @@
android:id="@+id/tabs" android:id="@+id/tabs"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:tabGravity="center"
app:tabMode="scrollable" /> app:tabMode="scrollable" />
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2

@ -1,39 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter" android:id="@+id/recyclerView"
android:layout_width="240dp" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:windowBackground"
android:clipToPadding="false" android:clipToPadding="false"
android:orientation="vertical" android:orientation="vertical"
android:scrollbars="vertical" android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" /> tools:listitem="@layout/item_manga_list" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/chapter_list_item_height" android:layout_height="@dimen/chapter_list_item_height"
@ -54,7 +55,8 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:src="@drawable/ic_new" /> android:src="@drawable/ic_new"
app:tint="?colorError" />
<ImageView <ImageView
android:id="@+id/imageView_downloaded" android:id="@+id/imageView_downloaded"

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall" android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground" android:background="?selectableItemBackground"
android:drawableStart="?android:listChoiceIndicatorMultiple" android:drawableStart="?android:listChoiceIndicatorMultiple"
android:drawablePadding="12dp" android:drawablePadding="12dp"
android:gravity="center_vertical|start" android:gravity="center_vertical|start"

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall" android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground" android:background="?selectableItemBackground"
android:drawableStart="?android:listChoiceIndicatorSingle" android:drawableStart="?android:listChoiceIndicatorSingle"
android:drawablePadding="12dp" android:drawablePadding="12dp"
android:gravity="center_vertical|start" android:gravity="center_vertical|start"

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
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="wrap_content">
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/textView_filter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
tools:text="@tools:sample/lorem[21]" />
<TextView
android:id="@+id/textView_filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/list_selector"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
app:drawableEndCompat="@drawable/ic_drop_down"
app:drawableTint="?android:attr/textColorSecondary"
tools:ignore="RtlSymmetry"
tools:text="@string/popular" />
</RelativeLayout>

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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="wrap_content"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:navigationIcon="@drawable/ic_cross"
app:title="@string/chapters" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chapter" />
</LinearLayout>

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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="wrap_content"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:navigationIcon="@drawable/ic_cross"
app:title="@string/filter" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" />
</LinearLayout>

@ -14,6 +14,10 @@
android:id="@+id/nav_history" android:id="@+id/nav_history"
android:icon="@drawable/ic_history" android:icon="@drawable/ic_history"
android:title="@string/history" /> android:title="@string/history" />
<item
android:id="@+id/nav_suggestions"
android:icon="@drawable/ic_suggestion"
android:title="@string/suggestions" />
<item <item
android:id="@+id/nav_feed" android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed" android:icon="@drawable/ic_feed"

@ -9,9 +9,4 @@
android:title="@string/list_mode" android:title="@string/list_mode"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_filter"
android:orderInCategory="30"
android:title="@string/filter"
app:showAsAction="never" />
</menu> </menu>

@ -3,6 +3,12 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_filter"
android:orderInCategory="30"
android:title="@string/filter"
app:showAsAction="never" />
<item <item
android:id="@+id/action_source_settings" android:id="@+id/action_source_settings"
android:orderInCategory="50" android:orderInCategory="50"

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_update"
android:orderInCategory="50"
android:title="@string/update"
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="90"
android:title="@string/settings"
app:showAsAction="never" />
</menu>

@ -54,11 +54,11 @@
<string name="automatic">Аўтаматычна</string> <string name="automatic">Аўтаматычна</string>
<string name="pages">Старонкi</string> <string name="pages">Старонкi</string>
<string name="clear">Ачысціць</string> <string name="clear">Ачысціць</string>
<string name="text_clear_history_prompt">Вы ўпэўненыя, што жадаеце ачысціць гісторыю\? Гэта дзеянне нельга будзе адмяніць.</string> <string name="text_clear_history_prompt">Вы ўпэўненыя, што жадаеце ачысціць гісторыю\?</string>
<string name="remove">Выдаліць</string> <string name="remove">Выдаліць</string>
<string name="_s_removed_from_history">\"%s\" выдалена з гiсторыi</string> <string name="_s_removed_from_history">\"%s\" выдалена з гiсторыi</string>
<string name="_s_deleted_from_local_storage">\"%s\" выдалена з прылады</string> <string name="_s_deleted_from_local_storage">\"%s\" выдалена з прылады</string>
<string name="wait_for_loading_finish">Дачакайцеся заканчэння загрузкі</string> <string name="wait_for_loading_finish">Дачакайцеся заканчэння загрузкі</string>
<string name="save_page">Захаваць старонку</string> <string name="save_page">Захаваць старонку</string>
<string name="page_saved">Старонка захавана</string> <string name="page_saved">Старонка захавана</string>
<string name="share_image">Падзяліцца выявай</string> <string name="share_image">Падзяліцца выявай</string>
@ -79,8 +79,7 @@
<string name="delete_manga">Выдаліць мангу</string> <string name="delete_manga">Выдаліць мангу</string>
<string name="reader_settings">Налады чытання</string> <string name="reader_settings">Налады чытання</string>
<string name="switch_pages">Гартанне старонак</string> <string name="switch_pages">Гартанне старонак</string>
<string name="text_delete_local_manga">Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\? <string name="text_delete_local_manga">Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\?</string>
\nГэта дзеянне нельга будзе адмяніць.</string>
<string name="taps_on_edges">Націск па краях</string> <string name="taps_on_edges">Націск па краях</string>
<string name="volume_buttons">Кнопкі гучнасці</string> <string name="volume_buttons">Кнопкі гучнасці</string>
<string name="_continue">Працягнцуць</string> <string name="_continue">Працягнцуць</string>
@ -207,7 +206,7 @@
<string name="password_length_hint">Пароль павінен змяшчаць не менш за 4 сімвалы</string> <string name="password_length_hint">Пароль павінен змяшчаць не менш за 4 сімвалы</string>
<string name="hide_toolbar">Схаваць загаловак пры прагортцы</string> <string name="hide_toolbar">Схаваць загаловак пры прагортцы</string>
<string name="search_only_on_s">Пошук толькі па %s</string> <string name="search_only_on_s">Пошук толькі па %s</string>
<string name="text_clear_search_history_prompt">Вы сапраўды хочаце выдаліць усе апошнія пошукавыя запыты\? Гэта дзеянне нельга будзе адмяніць.</string> <string name="text_clear_search_history_prompt">Вы сапраўды хочаце выдаліць усе апошнія пошукавыя запыты\?</string>
<string name="description">Апісанне</string> <string name="description">Апісанне</string>
<string name="read_more">Падрабязна</string> <string name="read_more">Падрабязна</string>
<string name="tracker_warning">Некаторыя вытворцы могуць змяняць паводзіны сістэмы, што можа парушаць выкананне фонавых задач.</string> <string name="tracker_warning">Некаторыя вытворцы могуць змяняць паводзіны сістэмы, што можа парушаць выкананне фонавых задач.</string>
@ -227,7 +226,7 @@
<string name="queued">У чарзе</string> <string name="queued">У чарзе</string>
<string name="about_license">Ліцэнзія</string> <string name="about_license">Ліцэнзія</string>
<string name="about_copyright_and_licenses">Аўтарскія правы і ліцэнзіі</string> <string name="about_copyright_and_licenses">Аўтарскія правы і ліцэнзіі</string>
<string name="about_gratitudes_summary">Гэтыя людзі робяць Kotatsu лепш!</string> <string name="about_gratitudes_summary">Гэтыя людзі робяць Kotatsu лепш</string>
<string name="about_gratitudes">Падзякі</string> <string name="about_gratitudes">Падзякі</string>
<string name="about_support_developer_summary">Калі вам падабаецца гэтая праграма, вы можаце дапамагчы фінансава з дапамогай ЮMoney (был. Яндекс.Деньги)</string> <string name="about_support_developer_summary">Калі вам падабаецца гэтая праграма, вы можаце дапамагчы фінансава з дапамогай ЮMoney (был. Яндекс.Деньги)</string>
<string name="about_support_developer">Падтрымаць распрацоўшчыка</string> <string name="about_support_developer">Падтрымаць распрацоўшчыка</string>
@ -251,4 +250,19 @@
<string name="available_sources">Даступныя крыніцы</string> <string name="available_sources">Даступныя крыніцы</string>
<string name="dynamic_theme">Дынамічная тэма</string> <string name="dynamic_theme">Дынамічная тэма</string>
<string name="dynamic_theme_summary">Ужывае тэму праграмы, заснаваную на каляровай палітры шпалер на прыладзе</string> <string name="dynamic_theme_summary">Ужывае тэму праграмы, заснаваную на каляровай палітры шпалер на прыладзе</string>
<string name="computing_">Вылічэнні…</string>
<string name="importing_progress">Імпарт мангі: %1$d of %2$d</string>
<string name="screenshots_allow">Дазваляць</string>
<string name="screenshots_policy">Палітыка скрыншотаў</string>
<string name="screenshots_block_all">Заўсёды блакуйце</string>
<string name="screenshots_block_nsfw">Блок на NSFW</string>
<string name="filter_load_error">Немагчыма загрузіць спіс жанраў</string>
<string name="disabled">Непрацаздольны</string>
<string name="enabled">Уключаны</string>
<string name="exclude_nsfw_from_suggestions">Не прапануйце мангу NSFW</string>
<string name="text_suggestion_holder">Пачніце чытаць мангу, і вы атрымаеце персаналізаваныя прапановы</string>
<string name="suggestions_info">Усе дадзеныя аналізуюцца лакальна на гэтай прыладзе. Перадача вашых персанальных дадзеных якім-небудзь сэрвісам не ажыццяўляецца</string>
<string name="suggestions_summary">Прапануеце мангу, заснаваную на вашых перавагах</string>
<string name="suggestions_enable">Уключыць прапановы</string>
<string name="suggestions">Прапанова</string>
</resources> </resources>

@ -256,4 +256,13 @@
<string name="screenshots_policy">Bildschirmfoto-Richtlinie</string> <string name="screenshots_policy">Bildschirmfoto-Richtlinie</string>
<string name="screenshots_block_nsfw">Für NSFW blockieren</string> <string name="screenshots_block_nsfw">Für NSFW blockieren</string>
<string name="screenshots_block_all">Immer blockieren</string> <string name="screenshots_block_all">Immer blockieren</string>
<string name="suggestions">Vorschläge</string>
<string name="suggestions_enable">Vorschläge einschalten</string>
<string name="suggestions_summary">Manga nach deinen Vorlieben vorschlagen</string>
<string name="suggestions_info">Alle Daten werden lokal auf diesem Gerät ausgewertet. Es findet keine Übertragung Ihrer persönlichen Daten an andere Dienste statt</string>
<string name="exclude_nsfw_from_suggestions">Keine NSFW-Manga vorschlagen</string>
<string name="enabled">Aktiviert</string>
<string name="text_suggestion_holder">Fang an, Manga zu lesen und du bekommst personalisierte Vorschläge</string>
<string name="disabled">Deaktiviert</string>
<string name="filter_load_error">Liste der Genres kann nicht geladen werden</string>
</resources> </resources>

@ -54,7 +54,7 @@
<string name="automatic">De acuerdo al sistema</string> <string name="automatic">De acuerdo al sistema</string>
<string name="pages">Páginas</string> <string name="pages">Páginas</string>
<string name="clear">Borrar</string> <string name="clear">Borrar</string>
<string name="text_clear_history_prompt">¿Realmente quieres borrar todo tu historial de lectura\? Esta acción no se puede deshacer.</string> <string name="text_clear_history_prompt">Borrar todo el historial de lectura de forma permanente\?</string>
<string name="remove">Eliminar</string> <string name="remove">Eliminar</string>
<string name="_s_removed_from_history">«%s» retirado del historial</string> <string name="_s_removed_from_history">«%s» retirado del historial</string>
<string name="_s_deleted_from_local_storage">«%s» borrado del almacenamiento local</string> <string name="_s_deleted_from_local_storage">«%s» borrado del almacenamiento local</string>
@ -72,7 +72,7 @@
<string name="cache">Caché</string> <string name="cache">Caché</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string> <string name="text_file_sizes">B|kB|MB|GB|TB</string>
<string name="standard">Estándar</string> <string name="standard">Estándar</string>
<string name="webtoon">Webtoon</string> <string name="webtoon">Sitio web</string>
<string name="read_mode">Modo de lectura</string> <string name="read_mode">Modo de lectura</string>
<string name="grid_size">Tamaño de la cuadrícula</string> <string name="grid_size">Tamaño de la cuadrícula</string>
<string name="search_on_s">Buscar en %s</string> <string name="search_on_s">Buscar en %s</string>
@ -158,7 +158,7 @@
<string name="update_check_failed">Fallo en la comprobación de actualizaciones</string> <string name="update_check_failed">Fallo en la comprobación de actualizaciones</string>
<string name="no_update_available">No hay actualizaciones disponibles</string> <string name="no_update_available">No hay actualizaciones disponibles</string>
<string name="right_to_left">Derecha a izquierda (←)</string> <string name="right_to_left">Derecha a izquierda (←)</string>
<string name="prefer_rtl_reader">Preferir lector de derecha a izquierda ()</string> <string name="prefer_rtl_reader">Preferir lector de derecha a izquierda ()</string>
<string name="prefer_rtl_reader_summary">Puedes configurar el modo de lectura para cada manga por separado</string> <string name="prefer_rtl_reader_summary">Puedes configurar el modo de lectura para cada manga por separado</string>
<string name="create_category">Nueva categoría</string> <string name="create_category">Nueva categoría</string>
<string name="report_github">Crear incidencia en GitHub</string> <string name="report_github">Crear incidencia en GitHub</string>
@ -177,7 +177,7 @@
<string name="preparing_">Preparando…</string> <string name="preparing_">Preparando…</string>
<string name="file_not_found">Archivo no encontrado</string> <string name="file_not_found">Archivo no encontrado</string>
<string name="data_restored_success">Todos los datos fueron restaurados con éxito</string> <string name="data_restored_success">Todos los datos fueron restaurados con éxito</string>
<string name="data_restored_with_errors">Los datos fueron restaurados, pero hay errores.</string> <string name="data_restored_with_errors">Los datos fueron restaurados, pero hay errores</string>
<string name="backup_information">Puedes crear una copia de seguridad de tu historial y favoritos para restaurarla</string> <string name="backup_information">Puedes crear una copia de seguridad de tu historial y favoritos para restaurarla</string>
<string name="just_now">Ahora mismo</string> <string name="just_now">Ahora mismo</string>
<string name="yesterday">Ayer</string> <string name="yesterday">Ayer</string>
@ -213,7 +213,7 @@
<string name="about_support_developer_summary">Si te gusta esta aplicación, puedes ayudar económicamente a través de Yoomoney (ex. Yandex.Money)</string> <string name="about_support_developer_summary">Si te gusta esta aplicación, puedes ayudar económicamente a través de Yoomoney (ex. Yandex.Money)</string>
<string name="about_support_developer">Apoyar al desarrollador</string> <string name="about_support_developer">Apoyar al desarrollador</string>
<string name="search_only_on_s">Buscar sólo en %s</string> <string name="search_only_on_s">Buscar sólo en %s</string>
<string name="about_gratitudes_summary">Todas estas personas hicieron que Kotatsu fuera mejor.</string> <string name="about_gratitudes_summary">Todas estas personas hicieron que Kotatsu fuera mejor</string>
<string name="about_license">Licencia</string> <string name="about_license">Licencia</string>
<string name="about_copyright_and_licenses">Derechos de autor y licencias</string> <string name="about_copyright_and_licenses">Derechos de autor y licencias</string>
<string name="chapter_is_missing">Falta un capítulo</string> <string name="chapter_is_missing">Falta un capítulo</string>
@ -231,7 +231,7 @@
<string name="other">Otro</string> <string name="other">Otro</string>
<string name="genres">Géneros</string> <string name="genres">Géneros</string>
<string name="text_search_holder_secondary">Intenta reformular la consulta.</string> <string name="text_search_holder_secondary">Intenta reformular la consulta.</string>
<string name="text_clear_search_history_prompt">¿Realmente quiere eliminar todas las consultas de búsqueda recientes\? Esta acción no se puede deshacer.</string> <string name="text_clear_search_history_prompt">¿Realmente quiere eliminar todas las consultas de búsqueda recientes\?</string>
<string name="state_finished">Terminado</string> <string name="state_finished">Terminado</string>
<string name="state_ongoing">En curso</string> <string name="state_ongoing">En curso</string>
<string name="hide_toolbar">Ocultar la barra de herramientas al desplazarse</string> <string name="hide_toolbar">Ocultar la barra de herramientas al desplazarse</string>
@ -243,11 +243,26 @@
<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="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">El nombre no debe estar vacío</string> <string name="error_empty_name">El nombre no debe estar vacío</string>
<string name="auth_not_supported_by">No se admite iniciar sesión en %s</string> <string name="auth_not_supported_by">No se admite iniciar sesión en %s</string>
<string name="text_clear_cookies_prompt">Serás desconectado de todas las fuentes.</string> <string name="text_clear_cookies_prompt">Serás desconectado de todas las fuentes</string>
<string name="exclude_nsfw_from_history">Excluye manga NSFW del historial</string> <string name="exclude_nsfw_from_history">Excluye manga NSFW del historial</string>
<string name="show_pages_numbers">Mostrar los números de páginas</string> <string name="show_pages_numbers">Mostrar los números de páginas</string>
<string name="enabled_sources">Fuentes activadas</string> <string name="enabled_sources">Fuentes activadas</string>
<string name="available_sources">Fuentes disponibles</string> <string name="available_sources">Fuentes disponibles</string>
<string name="dynamic_theme">Tema dinámico</string> <string name="dynamic_theme">Tema dinámico</string>
<string name="dynamic_theme_summary">Aplica un tema creado a partir del esquema de colores de su fondo de pantalla</string> <string name="dynamic_theme_summary">Aplica un tema creado a partir del esquema de colores de su fondo de pantalla</string>
<string name="computing_">Informática…</string>
<string name="importing_progress">Importando manga: %1$d de %2$d</string>
<string name="screenshots_policy">Política de capturas de pantalla</string>
<string name="screenshots_allow">Permitir</string>
<string name="screenshots_block_all">Bloquear siempre</string>
<string name="suggestions">Sugerencias</string>
<string name="suggestions_enable">Activar sugerencias</string>
<string name="suggestions_summary">Sugiere mangas según tus preferencias</string>
<string name="suggestions_info">Todos los datos se analizan localmente en este dispositivo. No hay transferencia de sus datos personales a ningún servicio</string>
<string name="text_suggestion_holder">Empieza a leer manga y recibirás sugerencias personalizadas</string>
<string name="exclude_nsfw_from_suggestions">No sugerir manga NSFW</string>
<string name="enabled">Activado</string>
<string name="disabled">Desactivado</string>
<string name="filter_load_error">No se puede cargar la lista de géneros</string>
<string name="screenshots_block_nsfw">Bloqueo en NSFW</string>
</resources> </resources>

@ -256,4 +256,13 @@
<string name="computing_">Lasketaan…</string> <string name="computing_">Lasketaan…</string>
<string name="available_sources">Käytettävissä olevat lähteet</string> <string name="available_sources">Käytettävissä olevat lähteet</string>
<string name="dynamic_theme">Dynaaminen teema</string> <string name="dynamic_theme">Dynaaminen teema</string>
<string name="suggestions">Ehdotukset</string>
<string name="suggestions_summary">Ehdota mangaa mieltymystesi perusteella</string>
<string name="suggestions_info">Kaikki tiedot analysoidaan paikallisesti tässä laitteessa. Henkilötietojasi ei siirretä mihinkään palveluihin</string>
<string name="suggestions_enable">Ota ehdotukset käyttöön</string>
<string name="exclude_nsfw_from_suggestions">Älä ehdota NSFW-mangaa</string>
<string name="text_suggestion_holder">Aloita mangan lukeminen ja saat henkilökohtaisia ehdotuksia</string>
<string name="enabled">Käytössä</string>
<string name="disabled">Pois päältä</string>
<string name="filter_load_error">Genreluetteloa ei voida ladata</string>
</resources> </resources>

@ -56,7 +56,7 @@
<string name="report_github">Signaler un problème sur GitHub</string> <string name="report_github">Signaler un problème sur GitHub</string>
<string name="create_category">Nouvelle catégorie</string> <string name="create_category">Nouvelle catégorie</string>
<string name="prefer_rtl_reader_summary">Le mode de lecture peut être configuré séparément pour chaque série</string> <string name="prefer_rtl_reader_summary">Le mode de lecture peut être configuré séparément pour chaque série</string>
<string name="prefer_rtl_reader">Préférer le lecteur de droite à gauche ()</string> <string name="prefer_rtl_reader">Préférer le lecteur de droite à gauche ()</string>
<string name="right_to_left">De droite à gauche (←)</string> <string name="right_to_left">De droite à gauche (←)</string>
<string name="no_update_available">Aucune mise à jour disponible</string> <string name="no_update_available">Aucune mise à jour disponible</string>
<string name="update_check_failed">Échec de la recherche de mise à jour</string> <string name="update_check_failed">Échec de la recherche de mise à jour</string>
@ -256,4 +256,13 @@
<string name="screenshots_block_all">Toujours bloquer</string> <string name="screenshots_block_all">Toujours bloquer</string>
<string name="screenshots_policy">Politique relative aux captures d\'écran</string> <string name="screenshots_policy">Politique relative aux captures d\'écran</string>
<string name="screenshots_allow">Autoriser</string> <string name="screenshots_allow">Autoriser</string>
<string name="suggestions">Suggestions</string>
<string name="exclude_nsfw_from_suggestions">Ne pas suggérer de mangas osés</string>
<string name="suggestions_enable">Activer les suggestions</string>
<string name="suggestions_summary">Suggérer des mangas en fonction de vos préférences</string>
<string name="suggestions_info">Toutes les données sont analysées localement sur cet appareil. Vos données personnelles ne sont pas transférées à d\'autres services</string>
<string name="text_suggestion_holder">Commencez à lire des mangas et vous recevrez des suggestions personnalisées</string>
<string name="filter_load_error">Impossible de charger la liste des genres</string>
<string name="enabled">Activé</string>
<string name="disabled">Désactivé</string>
</resources> </resources>

@ -256,4 +256,13 @@
<string name="screenshots_allow">Permetti</string> <string name="screenshots_allow">Permetti</string>
<string name="screenshots_block_nsfw">Blocca per NSFW</string> <string name="screenshots_block_nsfw">Blocca per NSFW</string>
<string name="screenshots_block_all">Blocca sempre</string> <string name="screenshots_block_all">Blocca sempre</string>
<string name="filter_load_error">Impossibile caricare la lista dei generi</string>
<string name="suggestions_enable">Abilita i suggerimenti</string>
<string name="suggestions_summary">Suggerisci manga in base alle tue preferenze</string>
<string name="suggestions_info">Tutti i dati sono analizzati localmente su questo dispositivo. Non c\'è trasferimento dei suoi dati personali a nessun servizio</string>
<string name="text_suggestion_holder">Inizia a leggere manga e riceverai suggerimenti personalizzati</string>
<string name="suggestions">Suggerimenti</string>
<string name="enabled">Abilitato</string>
<string name="disabled">Disabilitato</string>
<string name="exclude_nsfw_from_suggestions">Non suggerire manga NSFW</string>
</resources> </resources>

@ -164,7 +164,7 @@
<string name="update_check_failed">アップデートを見つける事が出来ませんでした</string> <string name="update_check_failed">アップデートを見つける事が出来ませんでした</string>
<string name="no_update_available">利用可能なアップデートはありません</string> <string name="no_update_available">利用可能なアップデートはありません</string>
<string name="right_to_left">右から左(←)</string> <string name="right_to_left">右から左(←)</string>
<string name="prefer_rtl_reader">右から左()の読書を好む</string> <string name="prefer_rtl_reader">右から左()の読書を好む</string>
<string name="about_feedback">フィードバック</string> <string name="about_feedback">フィードバック</string>
<string name="about_feedback_4pda">4PDAに関する話題</string> <string name="about_feedback_4pda">4PDAに関する話題</string>
<string name="about_support_developer">開発者をサポートします(Yoomoneyが開きます)</string> <string name="about_support_developer">開発者をサポートします(Yoomoneyが開きます)</string>
@ -256,4 +256,13 @@
<string name="screenshots_block_all">常にブロック</string> <string name="screenshots_block_all">常にブロック</string>
<string name="screenshots_policy">スクリーンショットポリシー</string> <string name="screenshots_policy">スクリーンショットポリシー</string>
<string name="screenshots_block_nsfw">NSFWでブロック</string> <string name="screenshots_block_nsfw">NSFWでブロック</string>
<string name="suggestions">提案</string>
<string name="suggestions_info">すべてのデータは、このデバイス上でローカルに分析されます。お客様のデータが他のサービスに転送されることはありません</string>
<string name="suggestions_enable">サジェスト機能を有効</string>
<string name="suggestions_summary">あなたの好みに合わせて漫画を提案</string>
<string name="filter_load_error">ジャンルリストを読み込めません</string>
<string name="disabled">無効</string>
<string name="text_suggestion_holder">マンガを読み始めると、個人的な提案を受けることができます</string>
<string name="enabled">有効</string>
<string name="exclude_nsfw_from_suggestions">NSFWのマンガを提案しない</string>
</resources> </resources>

@ -29,7 +29,7 @@
<string name="report_github">Opprett feilrapport på GitHub</string> <string name="report_github">Opprett feilrapport på GitHub</string>
<string name="prefer_rtl_reader_summary">Lesemodus kan settes opp for hver serie</string> <string name="prefer_rtl_reader_summary">Lesemodus kan settes opp for hver serie</string>
<string name="right_to_left">Høyre-til-venstre (←)</string> <string name="right_to_left">Høyre-til-venstre (←)</string>
<string name="prefer_rtl_reader">Foretrekk høyre-til-venstre ()-leser</string> <string name="prefer_rtl_reader">Foretrekk høyre-til-venstre ()-leser</string>
<string name="no_update_available">Ingen tilgjengelige oppdateringer</string> <string name="no_update_available">Ingen tilgjengelige oppdateringer</string>
<string name="update_check_failed">Kunne ikke se etter oppdateringer</string> <string name="update_check_failed">Kunne ikke se etter oppdateringer</string>
<string name="checking_for_updates">Ser etter oppdateringer …</string> <string name="checking_for_updates">Ser etter oppdateringer …</string>
@ -241,7 +241,7 @@
<string name="auth_not_supported_by">Innlogging på %s støttes ikke</string> <string name="auth_not_supported_by">Innlogging på %s støttes ikke</string>
<string name="text_clear_cookies_prompt">Du vil bli utlogget fra alle kilder</string> <string name="text_clear_cookies_prompt">Du vil bli utlogget fra alle kilder</string>
<string name="genres">Sjangere</string> <string name="genres">Sjangere</string>
<string name="exclude_nsfw_from_history">Utelat NSFW-manga fra historikk</string> <string name="exclude_nsfw_from_history">Utelat sensurerbar-manga fra historikk</string>
<string name="date_format">Datoformat</string> <string name="date_format">Datoformat</string>
<string name="system_default">Forvalg</string> <string name="system_default">Forvalg</string>
<string name="error_empty_name">Du må angi ett navn</string> <string name="error_empty_name">Du må angi ett navn</string>
@ -251,5 +251,18 @@
<string name="dynamic_theme">Dynamisk tema</string> <string name="dynamic_theme">Dynamisk tema</string>
<string name="dynamic_theme_summary">Bruker et tema basert på fargene til bakgrunnen din</string> <string name="dynamic_theme_summary">Bruker et tema basert på fargene til bakgrunnen din</string>
<string name="computing_">Beregner …</string> <string name="computing_">Beregner …</string>
<string name="importing_progress">Importerer manga: %1$d av %2$d</string> <string name="importing_progress">Importere manga: %1$d av %2$d</string>
<string name="screenshots_allow">Tillat</string>
<string name="screenshots_block_nsfw">Blokker for sensurerbare</string>
<string name="screenshots_block_all">Alltid blokker</string>
<string name="screenshots_policy">Skjermavbildningspraksis</string>
<string name="suggestions_enable">Skru på forslag</string>
<string name="suggestions">Forslag</string>
<string name="text_suggestion_holder">Du vil få personaliserte forslag når du begynner å lese manga</string>
<string name="suggestions_info">Alle data analyseres lokalt på denne enheten. Det er ingen overføring av dine personlige data til noen tjenester</string>
<string name="exclude_nsfw_from_suggestions">Ikke foreslå sensurerbar manga</string>
<string name="filter_load_error">Kunne ikke laste inn sjangerliste</string>
<string name="suggestions_summary">Foreslå manga basert på vaner</string>
<string name="enabled">Påskrudd</string>
<string name="disabled">Avskrudd</string>
</resources> </resources>

@ -202,7 +202,7 @@
<string name="chapter_is_missing">O capítulo está em falta</string> <string name="chapter_is_missing">O capítulo está em falta</string>
<string name="auth_complete">Autorizado</string> <string name="auth_complete">Autorizado</string>
<string name="auth_not_supported_by">O login em %s não é suportado</string> <string name="auth_not_supported_by">O login em %s não é suportado</string>
<string name="genres">Géneros</string> <string name="genres">Gêneros</string>
<string name="about_app_translation">Tradução</string> <string name="about_app_translation">Tradução</string>
<string name="text_clear_cookies_prompt">Você será desconectado de todas as fontes</string> <string name="text_clear_cookies_prompt">Você será desconectado de todas as fontes</string>
<string name="vibration">Vibração</string> <string name="vibration">Vibração</string>
@ -215,7 +215,7 @@
<string name="recent_manga">Recente</string> <string name="recent_manga">Recente</string>
<string name="other_storage">Outro armazenamento</string> <string name="other_storage">Outro armazenamento</string>
<string name="text_search_holder_secondary">Tente reformular a consulta.</string> <string name="text_search_holder_secondary">Tente reformular a consulta.</string>
<string name="prefer_rtl_reader">Prefira o leitor da direita para a esquerda ()</string> <string name="prefer_rtl_reader">Prefira o leitor da direita para a esquerda ()</string>
<string name="not_available">Não disponível</string> <string name="not_available">Não disponível</string>
<string name="size_s">Tamanho: %s</string> <string name="size_s">Tamanho: %s</string>
<string name="text_history_holder_primary">O que você ler será exibido aqui</string> <string name="text_history_holder_primary">O que você ler será exibido aqui</string>
@ -250,4 +250,19 @@
<string name="system_default">Padrão</string> <string name="system_default">Padrão</string>
<string name="dynamic_theme">Tema dinâmico</string> <string name="dynamic_theme">Tema dinâmico</string>
<string name="dynamic_theme_summary">Aplica um tema criado no esquema de cores do seu papel de parede</string> <string name="dynamic_theme_summary">Aplica um tema criado no esquema de cores do seu papel de parede</string>
<string name="computing_">Computando…</string>
<string name="importing_progress">Importando mangá: %1$d de %2$d</string>
<string name="screenshots_allow">Permitir</string>
<string name="screenshots_block_nsfw">Bloquear no NSFW</string>
<string name="screenshots_policy">Política de captura de tela</string>
<string name="screenshots_block_all">Sempre bloquear</string>
<string name="suggestions_summary">Sugira mangá com base em suas preferências</string>
<string name="suggestions_info">Todos os dados são analisados localmente neste dispositivo. Não há transferência de seus dados pessoais para nenhum serviço</string>
<string name="text_suggestion_holder">Comece a ler mangá e você receberá sugestões personalizadas</string>
<string name="suggestions">Sugestões</string>
<string name="suggestions_enable">Ativar sugestões</string>
<string name="exclude_nsfw_from_suggestions">Não sugira mangá NSFW</string>
<string name="enabled">Habilitado</string>
<string name="disabled">Desabilitado</string>
<string name="filter_load_error">Não foi possível carregar a lista de gêneros</string>
</resources> </resources>

@ -249,8 +249,20 @@
<string name="available_sources">Доступные источники</string> <string name="available_sources">Доступные источники</string>
<string name="dynamic_theme">Динамическая тема</string> <string name="dynamic_theme">Динамическая тема</string>
<string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string> <string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string>
<string name="screenshots_policy">Разрешить скриншоты</string> <string name="screenshots_policy">Политика скриншотов</string>
<string name="screenshots_allow">Разрешить</string> <string name="screenshots_allow">Разрешить</string>
<string name="screenshots_block_nsfw">Запретить для NSFW</string> <string name="screenshots_block_nsfw">Запретить для NSFW</string>
<string name="screenshots_block_all">Запретить всегда</string> <string name="screenshots_block_all">Всегда блокировать</string>
<string name="suggestions">Рекомендации</string>
<string name="suggestions_enable">Включить рекомендации</string>
<string name="suggestions_summary">Предлагать мангу на основе Ваших предпочтений</string>
<string name="suggestions_info">Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы</string>
<string name="text_suggestion_holder">Начните читать мангу, чтобы получать персональные предложения</string>
<string name="exclude_nsfw_from_suggestions">Не предлагать NSFW мангу</string>
<string name="enabled">Включено</string>
<string name="disabled">Выключено</string>
<string name="filter_load_error">Не удалось загрузить список жанров</string>
<string name="computing_">Вычисления…</string>
<string name="report_github">Создать проблему на GitHub</string>
<string name="importing_progress">Импорт манги: %1$d из %2$d</string>
</resources> </resources>

@ -204,7 +204,7 @@
<string name="search_results">Arama sonuçları</string> <string name="search_results">Arama sonuçları</string>
<string name="waiting_for_network">Ağ bekleniyor…</string> <string name="waiting_for_network">Ağ bekleniyor…</string>
<string name="repeat_password">Parolayı tekrarla</string> <string name="repeat_password">Parolayı tekrarla</string>
<string name="prefer_rtl_reader">Sağdan sola () okuyucuyu tercih et</string> <string name="prefer_rtl_reader">Sağdan sola () okuyucuyu tercih et</string>
<string name="dont_check">Denetleme</string> <string name="dont_check">Denetleme</string>
<string name="wrong_password">Yanlış parola</string> <string name="wrong_password">Yanlış parola</string>
<string name="report_github">GitHub\'da sorun oluştur</string> <string name="report_github">GitHub\'da sorun oluştur</string>
@ -256,4 +256,14 @@
<string name="screenshots_block_nsfw">Uygunsuzlarda engelle</string> <string name="screenshots_block_nsfw">Uygunsuzlarda engelle</string>
<string name="screenshots_block_all">Her zaman engelle</string> <string name="screenshots_block_all">Her zaman engelle</string>
<string name="screenshots_allow">İzin ver</string> <string name="screenshots_allow">İzin ver</string>
<string name="check_for_new_chapters">Yeni bölümleri denetle</string>
<string name="suggestions">Öneriler</string>
<string name="suggestions_enable">Önerileri etkinleştir</string>
<string name="suggestions_summary">Tercihlerinize göre manga önerileri alın</string>
<string name="suggestions_info">Tüm veriler aygıt üzerinde yerel olarak işlenir. Kişisel verilerinizin herhangi bir hizmete aktarılması söz konusu değildir</string>
<string name="text_suggestion_holder">Manga okumaya başladıktan sonra kişiselleştirilmiş öneriler alacaksınız</string>
<string name="exclude_nsfw_from_suggestions">Uygunsuz manga önerme</string>
<string name="enabled">Etkin</string>
<string name="disabled">Devre dışı</string>
<string name="filter_load_error">Türler listesi yüklenemiyor</string>
</resources> </resources>

@ -164,7 +164,7 @@
<string name="update_check_failed">Could not look for updates</string> <string name="update_check_failed">Could not look for updates</string>
<string name="no_update_available">No updates available</string> <string name="no_update_available">No updates available</string>
<string name="right_to_left">Right-to-left (←)</string> <string name="right_to_left">Right-to-left (←)</string>
<string name="prefer_rtl_reader">Prefer right-to-left () reader</string> <string name="prefer_rtl_reader">Prefer right-to-left () reader</string>
<string name="prefer_rtl_reader_summary">Reading mode can be set up separately for each series</string> <string name="prefer_rtl_reader_summary">Reading mode can be set up separately for each series</string>
<string name="create_category">New category</string> <string name="create_category">New category</string>
<string name="scale_mode">Scale mode</string> <string name="scale_mode">Scale mode</string>
@ -180,7 +180,7 @@
<string name="restore_backup">Restore from backup</string> <string name="restore_backup">Restore from backup</string>
<string name="data_restored">Restored</string> <string name="data_restored">Restored</string>
<string name="preparing_">Preparing…</string> <string name="preparing_">Preparing…</string>
<string name="report_github">Create issue on GitHub</string> <string name="report_github">Create issue on GitHub</string>
<string name="file_not_found">File not found</string> <string name="file_not_found">File not found</string>
<string name="data_restored_success">All data was restored</string> <string name="data_restored_success">All data was restored</string>
<string name="data_restored_with_errors">The data was restored, but there are errors</string> <string name="data_restored_with_errors">The data was restored, but there are errors</string>
@ -251,10 +251,19 @@
<string name="dynamic_theme">Dynamic theme</string> <string name="dynamic_theme">Dynamic theme</string>
<string name="dynamic_theme_summary">Applies a theme created on the color scheme of your wallpaper</string> <string name="dynamic_theme_summary">Applies a theme created on the color scheme of your wallpaper</string>
<string name="importing_progress">Importing manga: %1$d of %2$d</string> <string name="importing_progress">Importing manga: %1$d of %2$d</string>
<string name="screenshots_policy">Screenshots policy</string> <string name="screenshots_policy">Screenshot policy</string>
<string name="screenshots_allow">Allow</string> <string name="screenshots_allow">Allow</string>
<string name="screenshots_block_nsfw">Block on NSFW</string> <string name="screenshots_block_nsfw">Block on NSFW</string>
<string name="screenshots_block_all">Block always</string> <string name="screenshots_block_all">Always block</string>
<string name="suggestions">Suggestions</string>
<string name="suggestions_enable">Enable suggestions</string>
<string name="suggestions_summary">Suggest manga based on your preferences</string>
<string name="suggestions_info">All data is analyzed locally on this device. There is no transfer of your personal data to any services</string>
<string name="text_suggestion_holder">Start reading manga and you will get personalized suggestions</string>
<string name="exclude_nsfw_from_suggestions">Do not suggest NSFW manga</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="filter_load_error">Unable to load genres list</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="only_using_wifi">Only using WiFi</string> <string name="only_using_wifi">Only using WiFi</string>
<string name="always">Always</string> <string name="always">Always</string>

@ -61,6 +61,13 @@
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
android:key="suggestions"
android:persistent="false"
android:title="@string/suggestions"
app:iconSpaceReserved="false" />
<Preference <Preference
android:key="local_storage" android:key="local_storage"
android:title="@string/manga_save_location" android:title="@string/manga_save_location"
@ -71,7 +78,7 @@
android:title="@string/history_and_cache" android:title="@string/history_and_cache"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreference <SwitchPreferenceCompat
android:key="protect_app" android:key="protect_app"
android:persistent="false" android:persistent="false"
android:summary="@string/protect_application_summary" android:summary="@string/protect_application_summary"

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="suggestions"
android:summary="@string/suggestions_summary"
android:title="@string/suggestions_enable"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:dependency="suggestions"
android:key="suggestions_exclude_nsfw"
android:title="@string/exclude_nsfw_from_suggestions"
app:iconSpaceReserved="false" />
<Preference
android:icon="@drawable/ic_info_outline"
android:key="track_warning"
android:persistent="false"
android:selectable="false"
android:summary="@string/suggestions_info"
app:allowDividerAbove="true" />
</PreferenceScreen>
Loading…
Cancel
Save