Merge branch 'devel' into feature/page-preload

This commit is contained in:
Koitharu
2022-03-07 12:55:15 +02:00
97 changed files with 1414 additions and 500 deletions

View File

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

View File

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

View File

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

View File

@@ -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.TagEntity
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.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) {
@@ -45,4 +48,10 @@ class MangaDataRepository(private val db: MangaDatabase) {
db.mangaDao.upsert(MangaEntity.from(manga), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet {
it.toMangaTag()
}
}
}

View File

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

View File

@@ -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()
}
}

View File

@@ -29,6 +29,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
javaScriptEnabled = true
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
if (savedInstanceState != null) {
return
}
val url = intent?.dataString
if (url.isNullOrEmpty()) {
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 {
menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu)
@@ -82,6 +96,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
binding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
binding.webView.destroy()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading
}

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.toTitleCase
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 {
val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag(
title = a.text(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true
@@ -241,6 +247,8 @@ class AppSettings(context: Context) {
const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.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 {
val textView = (itemView as TextView)
@@ -16,4 +21,25 @@ fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header
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)
}
}

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
@@ -44,4 +47,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
bind {
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)
}
}

View File

@@ -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)
}
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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()
}
}

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class LocalListViewModel(
val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(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
override val content = combine(

View File

@@ -49,6 +49,8 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
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.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -122,6 +124,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -187,6 +190,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
@@ -285,7 +292,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (isLoading) {
binding.fab.setImageDrawable(CircularProgressDrawable(this).also {
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer)
it.strokeWidth = resources.resolveDp(2f)
it.strokeWidth = resources.resolveDp(3.5f)
it.start()
})
} else {
@@ -303,6 +310,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
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() {
when (viewModel.defaultSection) {
AppSection.LOCAL -> {
@@ -321,6 +336,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.navigationView.setCheckedItem(R.id.nav_feed)
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() {
lifecycleScope.launch(Dispatchers.Default) {
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).checkIfNeeded()
if (!get<AppSettings>().isSourcesSelected) {
withContext(Dispatchers.Main) {

View File

@@ -21,6 +21,12 @@ class MainViewModel(
val onOpenReader = SingleLiveEvent<Manga>()
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()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") }

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

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

View File

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

View File

@@ -4,12 +4,26 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
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
val remoteListModule
get() = module {
viewModel { source ->
RemoteListViewModel(get(named(source.get<MangaSource>())), get())
viewModel { params ->
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(),
)
}
}

View File

@@ -1,18 +1,22 @@
package org.koitharu.kotatsu.remotelist.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.FragmentResultListener
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
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.utils.ext.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment() {
class RemoteListFragment : MangaListFragment(), FragmentResultListener {
override val viewModel by viewModel<RemoteListViewModel> {
parametersOf(source)
@@ -20,6 +24,11 @@ class RemoteListFragment : MangaListFragment() {
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() {
viewModel.loadNextPage()
}
@@ -44,10 +53,26 @@ class RemoteListFragment : MangaListFragment() {
)
true
}
R.id.action_filter -> {
onFilterClick()
true
}
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 {
private const val ARG_SOURCE = "provider"

View File

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

View File

@@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference
import androidx.preference.TwoStatePreference
import kotlinx.coroutines.launch
import leakcanary.LeakCanary
import org.koin.android.ext.android.inject
@@ -56,7 +56,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<SwitchPreference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
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("")
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?) {
super.onViewCreated(view, savedInstanceState)
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.subscribe(this)
}
@@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.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
}
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? SwitchPreference ?: return false)
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))

View File

@@ -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)
}
}
}
}

View File

@@ -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()) }
}

View File

@@ -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)
}
}
}

View File

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

View File

@@ -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>
)

View File

@@ -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,
)

View File

@@ -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(),
)
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -6,22 +6,6 @@
android:layout_width="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
android:id="@+id/webView"
android:layout_width="0dp"
@@ -43,4 +27,20 @@
app:layout_constraintTop_toBottomOf="@id/appbar"
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>

View File

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

View File

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

View File

@@ -1,39 +1,21 @@
<?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
android:layout_width="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
android:id="@+id/recyclerView_filter"
android:layout_width="240dp"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:windowBackground"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
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>

View File

@@ -1,6 +1,7 @@
<?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="@dimen/chapter_list_item_height"
@@ -54,7 +55,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:src="@drawable/ic_new" />
android:src="@drawable/ic_new"
app:tint="?colorError" />
<ImageView
android:id="@+id/imageView_downloaded"

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -3,6 +3,12 @@
xmlns:android="http://schemas.android.com/apk/res/android"
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
android:id="@+id/action_source_settings"
android:orderInCategory="50"

View File

@@ -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>

View File

@@ -54,11 +54,11 @@
<string name="automatic">Аўтаматычна</string>
<string name="pages">Старонкi</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="_s_removed_from_history">\"%s\" выдалена з гiсторыi</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="page_saved">Старонка захавана</string>
<string name="share_image">Падзяліцца выявай</string>
@@ -79,8 +79,7 @@
<string name="delete_manga">Выдаліць мангу</string>
<string name="reader_settings">Налады чытання</string>
<string name="switch_pages">Гартанне старонак</string>
<string name="text_delete_local_manga">Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\?
\nГэта дзеянне нельга будзе адмяніць.</string>
<string name="text_delete_local_manga">Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\?</string>
<string name="taps_on_edges">Націск па краях</string>
<string name="volume_buttons">Кнопкі гучнасці</string>
<string name="_continue">Працягнцуць</string>
@@ -207,7 +206,7 @@
<string name="password_length_hint">Пароль павінен змяшчаць не менш за 4 сімвалы</string>
<string name="hide_toolbar">Схаваць загаловак пры прагортцы</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="read_more">Падрабязна</string>
<string name="tracker_warning">Некаторыя вытворцы могуць змяняць паводзіны сістэмы, што можа парушаць выкананне фонавых задач.</string>
@@ -227,7 +226,7 @@
<string name="queued">У чарзе</string>
<string name="about_license">Ліцэнзія</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_support_developer_summary">Калі вам падабаецца гэтая праграма, вы можаце дапамагчы фінансава з дапамогай ЮMoney (был. Яндекс.Деньги)</string>
<string name="about_support_developer">Падтрымаць распрацоўшчыка</string>
@@ -251,4 +250,19 @@
<string name="available_sources">Даступныя крыніцы</string>
<string name="dynamic_theme">Дынамічная тэма</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>

View File

@@ -256,4 +256,13 @@
<string name="screenshots_policy">Bildschirmfoto-Richtlinie</string>
<string name="screenshots_block_nsfw">Für NSFW 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>

View File

@@ -54,7 +54,7 @@
<string name="automatic">De acuerdo al sistema</string>
<string name="pages">Páginas</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="_s_removed_from_history">«%s» retirado del historial</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="text_file_sizes">B|kB|MB|GB|TB</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="grid_size">Tamaño de la cuadrícula</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="no_update_available">No hay actualizaciones disponibles</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="create_category">Nueva categoría</string>
<string name="report_github">Crear incidencia en GitHub</string>
@@ -177,7 +177,7 @@
<string name="preparing_">Preparando…</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_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="just_now">Ahora mismo</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">Apoyar al desarrollador</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_copyright_and_licenses">Derechos de autor y licencias</string>
<string name="chapter_is_missing">Falta un capítulo</string>
@@ -231,7 +231,7 @@
<string name="other">Otro</string>
<string name="genres">Géneros</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_ongoing">En curso</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="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="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="show_pages_numbers">Mostrar los números de páginas</string>
<string name="enabled_sources">Fuentes activadas</string>
<string name="available_sources">Fuentes disponibles</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="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>

View File

@@ -256,4 +256,13 @@
<string name="computing_">Lasketaan…</string>
<string name="available_sources">Käytettävissä olevat lähteet</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>

View File

@@ -56,7 +56,7 @@
<string name="report_github">Signaler un problème sur GitHub</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">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="no_update_available">Aucune mise à jour disponible</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_policy">Politique relative aux captures d\'écran</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>

View File

@@ -256,4 +256,13 @@
<string name="screenshots_allow">Permetti</string>
<string name="screenshots_block_nsfw">Blocca per NSFW</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>

View File

@@ -164,7 +164,7 @@
<string name="update_check_failed">アップデートを見つける事が出来ませんでした</string>
<string name="no_update_available">利用可能なアップデートはありません</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_4pda">4PDAに関する話題</string>
<string name="about_support_developer">開発者をサポートします(Yoomoneyが開きます)</string>
@@ -256,4 +256,13 @@
<string name="screenshots_block_all">常にブロック</string>
<string name="screenshots_policy">スクリーンショットポリシー</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>

View File

@@ -29,7 +29,7 @@
<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="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="update_check_failed">Kunne ikke se 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="text_clear_cookies_prompt">Du vil bli utlogget fra alle kilder</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="system_default">Forvalg</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_summary">Bruker et tema basert på fargene til bakgrunnen din</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>

View File

@@ -202,7 +202,7 @@
<string name="chapter_is_missing">O capítulo está em falta</string>
<string name="auth_complete">Autorizado</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="text_clear_cookies_prompt">Você será desconectado de todas as fontes</string>
<string name="vibration">Vibração</string>
@@ -215,7 +215,7 @@
<string name="recent_manga">Recente</string>
<string name="other_storage">Outro armazenamento</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="size_s">Tamanho: %s</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="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="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>

View File

@@ -249,8 +249,20 @@
<string name="available_sources">Доступные источники</string>
<string name="dynamic_theme">Динамическая тема</string>
<string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string>
<string name="screenshots_policy">Разрешить скриншоты</string>
<string name="screenshots_allow">Разрешить</string>
<string name="screenshots_block_nsfw">Запретить для NSFW</string>
<string name="screenshots_block_all">Запретить всегда</string>
<string name="screenshots_policy">Политика скриншотов</string>
<string name="screenshots_allow">Разрешить</string>
<string name="screenshots_block_nsfw">Запретить для NSFW</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>

View File

@@ -204,7 +204,7 @@
<string name="search_results">Arama sonuçları</string>
<string name="waiting_for_network">Ağ bekleniyor…</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="wrong_password">Yanlış parola</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_all">Her zaman engelle</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>

View File

@@ -164,7 +164,7 @@
<string name="update_check_failed">Could not look for updates</string>
<string name="no_update_available">No updates available</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="create_category">New category</string>
<string name="scale_mode">Scale mode</string>
@@ -180,7 +180,7 @@
<string name="restore_backup">Restore from backup</string>
<string name="data_restored">Restored</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="data_restored_success">All data was restored</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_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="screenshots_policy">Screenshots policy</string>
<string name="screenshots_policy">Screenshot policy</string>
<string name="screenshots_allow">Allow</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="only_using_wifi">Only using WiFi</string>
<string name="always">Always</string>

View File

@@ -61,6 +61,13 @@
app:allowDividerAbove="true"
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
android:key="local_storage"
android:title="@string/manga_save_location"
@@ -71,7 +78,7 @@
android:title="@string/history_and_cache"
app:iconSpaceReserved="false" />
<SwitchPreference
<SwitchPreferenceCompat
android:key="protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"

View File

@@ -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>