diff --git a/app/build.gradle b/app/build.gradle index d8ccb45bf..de3d3fa5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a666a295e..d42998bc7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,7 @@ { + return db.tagsDao.findTags(source.name).mapToSet { + it.toMangaTag() + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt index b3df5277a..8d1c5e279 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -33,7 +33,7 @@ abstract class BaseViewModel : ViewModel() { } } - private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> + protected fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> if (BuildConfig.DEBUG) { throwable.printStackTrace() } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt deleted file mode 100644 index f17b31f84..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt index 497d18499..885e99b70 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -29,6 +29,10 @@ class BrowserActivity : BaseActivity(), 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(), 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(), BrowserCallback binding.webView.onResume() } + override fun onDestroy() { + super.onDestroy() + binding.webView.destroy() + } + override fun onLoadingStateChanged(isLoading: Boolean) { binding.progressBar.isVisible = isLoading } diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt new file mode 100644 index 000000000..0d890397e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 04b1ba764..89d90665e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt index 0cd94ba37..7f9655d19 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt @@ -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 + @Query("SELECT * FROM tags WHERE source = :source") + abstract suspend fun findTags(source: String): List @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(tag: TagEntity): Long diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 3e25b0bed..6b3b6a94b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -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) ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt deleted file mode 100644 index 498492f24..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt +++ /dev/null @@ -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, -) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 4a2c2be82..f66e4b14f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -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 get() = emptySet() - override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain() override suspend fun getTags(): Set = emptySet() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt index e71378eec..beb969daf 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt @@ -237,7 +237,6 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor when { c == '-' -> { builder.setCharAt(i, ' ') - capitalize = true } capitalize -> { builder.setCharAt(i, c.uppercaseChar()) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt index 82a0a3268..ceb4ee024 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt index 0b5fd9b65..308f209b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt @@ -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() ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt index 41b86750e..8fb6ee6a7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt @@ -17,6 +17,8 @@ class ExHentaiRepository( override val source = MangaSource.EXHENTAI + override val sortOrders: Set = 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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt index 598a43bf0..c1f49d804 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt index 072c7611b..2c25870b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt index 2b289212b..57e57c025 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt @@ -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, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt index ed58f073c..8ebbf2c44 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt @@ -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 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt index 5e5429d95..a9c0030d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt index afe3750c3..973bb77bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt @@ -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() ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt index 2b2f9b8cd..6aa94cb98 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt @@ -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, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt index 7b782ab1c..351467882 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt @@ -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, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt index ebea9bc94..d3925d62a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt index 64ce67264..0efa45c92 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt @@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs enum class AppSection { - LOCAL, FAVOURITES, HISTORY, FEED + LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index f20afcc48..c6dfdb5c6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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" diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt index 9ee2642fb..0b973aa64 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -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 + @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 + @Query("SELECT * FROM history WHERE manga_id = :id") abstract suspend fun find(id: Long): HistoryEntity? diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 220f06dca..c492e0e0b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -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 { + return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 97fa18c4c..97664dd2f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -85,7 +85,7 @@ class HistoryListViewModel( val result = ArrayList(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) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index a91d3924b..00bd769fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -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(), 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(), spanSizeLookup.invalidateCache() } open val isSwipeRefreshEnabled = true - private var drawer: DrawerLayout? = null protected abstract val viewModel: MangaListViewModel @@ -67,16 +59,14 @@ abstract class MangaListFragment : BaseFragment(), 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(), 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(), } override fun onDestroyView() { - drawer = null listAdapter = null - filterAdapter = null paginationListener = null spanSizeLookup.invalidateCache() super.onDestroyView() @@ -125,19 +108,9 @@ abstract class MangaListFragment : BaseFragment(), 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(), } } - protected fun onInitFilter(filter: List) { - 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(), } } + protected open fun onFilterClick() = Unit + private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver.setGridSize(scale, binding.recyclerView) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index e2a463f4e..6a04449b6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -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> - val filter = MutableLiveData>() val listMode = MutableLiveData() 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(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() diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt index 4d25060ac..53ac01484 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -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(R.layout.item_header) { +fun listHeaderAD() = adapterDelegate( + 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(R.layout.item_header textView.setText(item.textRes) } } +} + +fun listHeaderWithFilterAD( + onFilterClickListener: () -> Unit, +) = adapterDelegateViewBinding( + 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) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index 61cd60c03..714f04473 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -20,6 +20,7 @@ class MangaListAdapter( clickListener: OnListItemClickListener, onRetryClick: (Throwable) -> Unit, onTagRemoveClick: (MangaTag) -> Unit, + onFilterClickListener: () -> Unit, ) : AsyncListDifferDelegationAdapter(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() { @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt similarity index 81% rename from app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt index 67b4d3585..19b3f11f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt @@ -2,11 +2,13 @@ package org.koitharu.kotatsu.list.ui.filter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -class FilterAdapter2( +class FilterAdapter( listener: OnFilterChangedListener, ) : AsyncListDifferDelegationAdapter( FilterDiffCallback(), filterSortDelegate(listener), filterTagDelegate(listener), filterHeaderDelegate(), + filterLoadingDelegate(), + filterErrorDelegate(), ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt index 8b926d768..073de2c9d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt @@ -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(R.layout.item_loading_footer) {} + +fun filterErrorDelegate() = adapterDelegate(R.layout.item_sources_empty) { + + bind { + (itemView as TextView).setText(item.textResId) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt new file mode 100644 index 000000000..70aef4326 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -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() { + + private val viewModel by sharedViewModel( + owner = { from(requireParentFragment(), requireParentFragment()) } + ) { + parametersOf( + requireArguments().getParcelable(ARG_SOURCE), + requireArguments().getParcelable(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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt index 1ccd4e813..73e3db315 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt @@ -6,6 +6,7 @@ class FilterDiffCallback : DiffUtil.ItemCallback() { 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() { 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 } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt index a74d93b1d..75b29e60d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt @@ -19,4 +19,10 @@ sealed interface FilterItem { val tag: MangaTag, val isChecked: Boolean, ) : FilterItem + + object Loading : FilterItem + + class Error( + @StringRes val textResId: Int, + ) : FilterItem } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt new file mode 100644 index 000000000..1c1c8a9cf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt @@ -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, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt new file mode 100644 index 000000000..06c4b029e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt @@ -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>() + val result = MutableLiveData() + 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(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(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(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? { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt index 209c7227f..a14db0f3a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index f678b83b7..791e9985a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -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 ) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 3f721355a..b68af3a0a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -32,7 +32,7 @@ class LocalListViewModel( val importProgress = MutableLiveData(null) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(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( diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 380b4942b..329591adc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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(), 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(), 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(), 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(), 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(), 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(), private fun onFirstStart() { lifecycleScope.launch(Dispatchers.Default) { TrackWorker.setup(applicationContext) + SuggestionsWorker.setup(applicationContext) AppUpdateChecker(this@MainActivity).checkIfNeeded() if (!get().isSourcesSelected) { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index ec9566e68..f197e454c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -21,6 +21,12 @@ class MainViewModel( val onOpenReader = SingleLiveEvent() 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("") } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt new file mode 100644 index 000000000..1ec6ffa7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt @@ -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(), OnListItemClickListener { + + 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(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().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, + currentId: Long, + ) = ChaptersBottomSheet().withArgs(2) { + putParcelableArrayList(ARG_CHAPTERS, chapters.asArrayList()) + putLong(ARG_CURRENT_ID, currentId) + }.show(fm, TAG) + + private fun List.asArrayList(): ArrayList { + return this as? ArrayList ?: ArrayList(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt deleted file mode 100644 index f091de3fd..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt +++ /dev/null @@ -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(), - OnListItemClickListener { - - 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(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().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, currentId: Long = 0L) = - ChaptersDialog().withArgs(2) { - putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters)) - putLong(ARG_CURRENT_ID, currentId) - }.show(fm, TAG) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index e4ee21e84..0f74db220 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -51,7 +51,7 @@ import org.koitharu.kotatsu.utils.anim.Motion import org.koitharu.kotatsu.utils.ext.* class ReaderActivity : BaseFullscreenActivity(), - ChaptersDialog.OnChapterChangeListener, + ChaptersBottomSheet.OnChapterChangeListener, GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, ActivityResultCallback, ReaderControlDelegate.OnInteractionListener { @@ -152,7 +152,7 @@ class ReaderActivity : BaseFullscreenActivity(), startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this)) } R.id.action_chapters -> { - ChaptersDialog.show( + ChaptersBottomSheet.show( supportFragmentManager, viewModel.manga?.chapters.orEmpty(), viewModel.getCurrentState()?.chapterId ?: 0L diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt index 8efab6827..c32605b96 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt @@ -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() { @@ -27,9 +24,11 @@ class SimpleSettingsActivity : BaseActivity() { 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() { 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() { 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt index 4555fe10e..4d35a857f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt @@ -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())), get()) + viewModel { params -> + RemoteListViewModel( + repository = get(named(params.get())) as RemoteMangaRepository, + settings = get(), + ) + } + + viewModel { params -> + FilterViewModel( + repository = get(named(params.get())) as RemoteMangaRepository, + dataRepository = get(), + state = params.get(), + ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 5ae3a92da..04a1ffefe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -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 { parametersOf(source) @@ -20,6 +24,11 @@ class RemoteListFragment : MangaListFragment() { private val source by parcelableArgument(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" diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index e92616d4c..c914e709f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -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?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(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(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() - } - } - } - } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 17e8b6a93..7a2b992ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -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(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = + findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable findPreference(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(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(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() - findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = + findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = !settings.appPassword.isNullOrEmpty() settings.subscribe(this) } @@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_HIDE_TOOLBAR -> { - findPreference(key)?.setSummary(R.string.restart_required) + findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_LOCAL_STORAGE -> { findPreference(key)?.bindStorageName() } AppSettings.KEY_APP_PASSWORD -> { - findPreference(AppSettings.KEY_PROTECT_APP) + findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } + AppSettings.KEY_SUGGESTIONS -> { + findPreference(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)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt new file mode 100644 index 000000000..02467f1d6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt @@ -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(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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt new file mode 100644 index 000000000..df0a2c870 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt @@ -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()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt new file mode 100644 index 000000000..0f80321a0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -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> + + @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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt similarity index 77% rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt index 4459d56b8..97212bf2e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt @@ -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(), ) diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt new file mode 100644 index 000000000..13aa11bec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt new file mode 100644 index 000000000..689d8276a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt new file mode 100644 index 000000000..aec0a948d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -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> { + 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) { + 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(), + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt new file mode 100644 index 000000000..c70793405 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -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() + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt new file mode 100644 index 000000000..21090bf5f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -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(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 +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt new file mode 100644 index 000000000..f602e534d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -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() + private val historyRepository by inject() + private val appSettings by inject() + + 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() + 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(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() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .build() + WorkManager.getInstance(context) + .enqueue(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 7f487e3e4..3f5d5263a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -80,7 +80,7 @@ class FeedFragment : BaseFragment(), PaginationScrollListen Snackbar.make( binding.recyclerView, R.string.feed_will_update_soon, - Snackbar.LENGTH_SHORT + Snackbar.LENGTH_LONG, ).show() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 7d20f6f43..7c982d601 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -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() .setConstraints(constraints) - .addTag(TAG) + .addTag(TAG_ONESHOT) .build() WorkManager.getInstance(context) .enqueue(request) diff --git a/app/src/main/res/drawable/ic_suggestion.xml b/app/src/main/res/drawable/ic_suggestion.xml new file mode 100644 index 000000000..a93a75799 --- /dev/null +++ b/app/src/main/res/drawable/ic_suggestion.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp/fragment_list.xml b/app/src/main/res/layout-w600dp/fragment_list.xml deleted file mode 100644 index c3041768c..000000000 --- a/app/src/main/res/layout-w600dp/fragment_list.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml index b9a3c6992..729f00739 100644 --- a/app/src/main/res/layout/activity_browser.xml +++ b/app/src/main/res/layout/activity_browser.xml @@ -6,22 +6,6 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b944dcc54..446d1ae2f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -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"> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_favourites.xml b/app/src/main/res/layout/fragment_favourites.xml index 47b8c515d..0f7e61994 100644 --- a/app/src/main/res/layout/fragment_favourites.xml +++ b/app/src/main/res/layout/fragment_favourites.xml @@ -10,7 +10,6 @@ android:id="@+id/tabs" android:layout_width="match_parent" android:layout_height="wrap_content" - app:tabGravity="center" app:tabMode="scrollable" /> - - - - - - - + tools:listitem="@layout/item_manga_list" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_chapter.xml b/app/src/main/res/layout/item_chapter.xml index fda5fe0f2..30f631e10 100644 --- a/app/src/main/res/layout/item_chapter.xml +++ b/app/src/main/res/layout/item_chapter.xml @@ -1,6 +1,7 @@ + android:src="@drawable/ic_new" + app:tint="?colorError" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_chapters.xml b/app/src/main/res/layout/sheet_chapters.xml new file mode 100644 index 000000000..102aaded5 --- /dev/null +++ b/app/src/main/res/layout/sheet_chapters.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml new file mode 100644 index 000000000..b7343028b --- /dev/null +++ b/app/src/main/res/layout/sheet_filter.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/nav_drawer.xml b/app/src/main/res/menu/nav_drawer.xml index 75869c7a7..1e0db493c 100644 --- a/app/src/main/res/menu/nav_drawer.xml +++ b/app/src/main/res/menu/nav_drawer.xml @@ -14,6 +14,10 @@ android:id="@+id/nav_history" android:icon="@drawable/ic_history" android:title="@string/history" /> + - \ No newline at end of file diff --git a/app/src/main/res/menu/opt_list_remote.xml b/app/src/main/res/menu/opt_list_remote.xml index deb531840..5df3276f1 100644 --- a/app/src/main/res/menu/opt_list_remote.xml +++ b/app/src/main/res/menu/opt_list_remote.xml @@ -3,6 +3,12 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 2831077f0..2155f5363 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -54,11 +54,11 @@ Аўтаматычна Старонкi Ачысціць - Вы ўпэўненыя, што жадаеце ачысціць гісторыю\? Гэта дзеянне нельга будзе адмяніць. + Вы ўпэўненыя, што жадаеце ачысціць гісторыю\? Выдаліць \"%s\" выдалена з гiсторыi \"%s\" выдалена з прылады - Дачакайцеся заканчэння загрузкі + Дачакайцеся заканчэння загрузкі… Захаваць старонку Старонка захавана Падзяліцца выявай @@ -79,8 +79,7 @@ Выдаліць мангу Налады чытання Гартанне старонак - Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\? -\nГэта дзеянне нельга будзе адмяніць. + Вы ўпэўненыя, што жадаеце выдаліць \"%s\" з прылады\? Націск па краях Кнопкі гучнасці Працягнцуць @@ -207,7 +206,7 @@ Пароль павінен змяшчаць не менш за 4 сімвалы Схаваць загаловак пры прагортцы Пошук толькі па %s - Вы сапраўды хочаце выдаліць усе апошнія пошукавыя запыты\? Гэта дзеянне нельга будзе адмяніць. + Вы сапраўды хочаце выдаліць усе апошнія пошукавыя запыты\? Апісанне Падрабязна Некаторыя вытворцы могуць змяняць паводзіны сістэмы, што можа парушаць выкананне фонавых задач. @@ -227,7 +226,7 @@ У чарзе Ліцэнзія Аўтарскія правы і ліцэнзіі - Гэтыя людзі робяць Kotatsu лепш! + Гэтыя людзі робяць Kotatsu лепш Падзякі Калі вам падабаецца гэтая праграма, вы можаце дапамагчы фінансава з дапамогай ЮMoney (был. Яндекс.Деньги) Падтрымаць распрацоўшчыка @@ -251,4 +250,19 @@ Даступныя крыніцы Дынамічная тэма Ужывае тэму праграмы, заснаваную на каляровай палітры шпалер на прыладзе + Вылічэнні… + Імпарт мангі: %1$d of %2$d + Дазваляць + Палітыка скрыншотаў + Заўсёды блакуйце + Блок на NSFW + Немагчыма загрузіць спіс жанраў + Непрацаздольны + Уключаны + Не прапануйце мангу NSFW + Пачніце чытаць мангу, і вы атрымаеце персаналізаваныя прапановы + Усе дадзеныя аналізуюцца лакальна на гэтай прыладзе. Перадача вашых персанальных дадзеных якім-небудзь сэрвісам не ажыццяўляецца + Прапануеце мангу, заснаваную на вашых перавагах + Уключыць прапановы + Прапанова \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 147962f9d..036e2db27 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -256,4 +256,13 @@ Bildschirmfoto-Richtlinie Für NSFW blockieren Immer blockieren + Vorschläge + Vorschläge einschalten + Manga nach deinen Vorlieben vorschlagen + Alle Daten werden lokal auf diesem Gerät ausgewertet. Es findet keine Übertragung Ihrer persönlichen Daten an andere Dienste statt + Keine NSFW-Manga vorschlagen + Aktiviert + Fang an, Manga zu lesen und du bekommst personalisierte Vorschläge + Deaktiviert + Liste der Genres kann nicht geladen werden \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf9fa937a..a4c00187e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -54,7 +54,7 @@ De acuerdo al sistema Páginas Borrar - ¿Realmente quieres borrar todo tu historial de lectura\? Esta acción no se puede deshacer. + Borrar todo el historial de lectura de forma permanente\? Eliminar «%s» retirado del historial «%s» borrado del almacenamiento local @@ -72,7 +72,7 @@ Caché B|kB|MB|GB|TB Estándar - Webtoon + Sitio web Modo de lectura Tamaño de la cuadrícula Buscar en %s @@ -158,7 +158,7 @@ Fallo en la comprobación de actualizaciones No hay actualizaciones disponibles Derecha a izquierda (←) - Preferir lector de derecha a izquierda (→) + Preferir lector de derecha a izquierda (←) Puedes configurar el modo de lectura para cada manga por separado Nueva categoría Crear incidencia en GitHub @@ -177,7 +177,7 @@ Preparando… Archivo no encontrado Todos los datos fueron restaurados con éxito - Los datos fueron restaurados, pero hay errores. + Los datos fueron restaurados, pero hay errores Puedes crear una copia de seguridad de tu historial y favoritos para restaurarla Ahora mismo Ayer @@ -213,7 +213,7 @@ Si te gusta esta aplicación, puedes ayudar económicamente a través de Yoomoney (ex. Yandex.Money) Apoyar al desarrollador Buscar sólo en %s - Todas estas personas hicieron que Kotatsu fuera mejor. + Todas estas personas hicieron que Kotatsu fuera mejor Licencia Derechos de autor y licencias Falta un capítulo @@ -231,7 +231,7 @@ Otro Géneros Intenta reformular la consulta. - ¿Realmente quiere eliminar todas las consultas de búsqueda recientes\? Esta acción no se puede deshacer. + ¿Realmente quiere eliminar todas las consultas de búsqueda recientes\? Terminado En curso Ocultar la barra de herramientas al desplazarse @@ -243,11 +243,26 @@ Algunos fabricantes pueden cambiar el comportamiento del sistema, lo que podría interrumpir las tareas en segundo plano. El nombre no debe estar vacío No se admite iniciar sesión en %s - Serás desconectado de todas las fuentes. + Serás desconectado de todas las fuentes Excluye manga NSFW del historial Mostrar los números de páginas Fuentes activadas Fuentes disponibles Tema dinámico Aplica un tema creado a partir del esquema de colores de su fondo de pantalla + Informática… + Importando manga: %1$d de %2$d + Política de capturas de pantalla + Permitir + Bloquear siempre + Sugerencias + Activar sugerencias + Sugiere mangas según tus preferencias + Todos los datos se analizan localmente en este dispositivo. No hay transferencia de sus datos personales a ningún servicio + Empieza a leer manga y recibirás sugerencias personalizadas + No sugerir manga NSFW + Activado + Desactivado + No se puede cargar la lista de géneros + Bloqueo en NSFW \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 19f03a87d..960898a61 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -256,4 +256,13 @@ Lasketaan… Käytettävissä olevat lähteet Dynaaminen teema + Ehdotukset + Ehdota mangaa mieltymystesi perusteella + Kaikki tiedot analysoidaan paikallisesti tässä laitteessa. Henkilötietojasi ei siirretä mihinkään palveluihin + Ota ehdotukset käyttöön + Älä ehdota NSFW-mangaa + Aloita mangan lukeminen ja saat henkilökohtaisia ehdotuksia + Käytössä + Pois päältä + Genreluetteloa ei voida ladata \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c303c19a4..3824772ec 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -56,7 +56,7 @@ Signaler un problème sur GitHub Nouvelle catégorie Le mode de lecture peut être configuré séparément pour chaque série - Préférer le lecteur de droite à gauche (→) + Préférer le lecteur de droite à gauche (←) De droite à gauche (←) Aucune mise à jour disponible Échec de la recherche de mise à jour @@ -256,4 +256,13 @@ Toujours bloquer Politique relative aux captures d\'écran Autoriser + Suggestions + Ne pas suggérer de mangas osés + Activer les suggestions + Suggérer des mangas en fonction de vos préférences + Toutes les données sont analysées localement sur cet appareil. Vos données personnelles ne sont pas transférées à d\'autres services + Commencez à lire des mangas et vous recevrez des suggestions personnalisées + Impossible de charger la liste des genres + Activé + Désactivé \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8cbe33553..71e33d82d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -256,4 +256,13 @@ Permetti Blocca per NSFW Blocca sempre + Impossibile caricare la lista dei generi + Abilita i suggerimenti + Suggerisci manga in base alle tue preferenze + Tutti i dati sono analizzati localmente su questo dispositivo. Non c\'è trasferimento dei suoi dati personali a nessun servizio + Inizia a leggere manga e riceverai suggerimenti personalizzati + Suggerimenti + Abilitato + Disabilitato + Non suggerire manga NSFW \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bbc3fee2a..2f196fc8f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -164,7 +164,7 @@ アップデートを見つける事が出来ませんでした 利用可能なアップデートはありません 右から左(←) - 右から左(→)の読書を好む + 右から左(←)の読書を好む フィードバック 4PDAに関する話題 開発者をサポートします(Yoomoneyが開きます) @@ -256,4 +256,13 @@ 常にブロック スクリーンショットポリシー NSFWでブロック + 提案 + すべてのデータは、このデバイス上でローカルに分析されます。お客様のデータが他のサービスに転送されることはありません + サジェスト機能を有効 + あなたの好みに合わせて漫画を提案 + ジャンルリストを読み込めません + 無効 + マンガを読み始めると、個人的な提案を受けることができます + 有効 + NSFWのマンガを提案しない \ No newline at end of file diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 26ee3bf54..2078c044f 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -29,7 +29,7 @@ Opprett feilrapport på GitHub Lesemodus kan settes opp for hver serie Høyre-til-venstre (←) - Foretrekk høyre-til-venstre (→)-leser + Foretrekk høyre-til-venstre (←)-leser Ingen tilgjengelige oppdateringer Kunne ikke se etter oppdateringer Ser etter oppdateringer … @@ -241,7 +241,7 @@ Innlogging på %s støttes ikke Du vil bli utlogget fra alle kilder Sjangere - Utelat NSFW-manga fra historikk + Utelat sensurerbar-manga fra historikk Datoformat Forvalg Du må angi ett navn @@ -251,5 +251,18 @@ Dynamisk tema Bruker et tema basert på fargene til bakgrunnen din Beregner … - Importerer manga: %1$d av %2$d + Importere manga: %1$d av %2$d + Tillat + Blokker for sensurerbare + Alltid blokker + Skjermavbildningspraksis + Skru på forslag + Forslag + Du vil få personaliserte forslag når du begynner å lese manga + Alle data analyseres lokalt på denne enheten. Det er ingen overføring av dine personlige data til noen tjenester + Ikke foreslå sensurerbar manga + Kunne ikke laste inn sjangerliste + Foreslå manga basert på vaner + Påskrudd + Avskrudd \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fc076e56d..5732181c8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -202,7 +202,7 @@ O capítulo está em falta Autorizado O login em %s não é suportado - Géneros + Gêneros Tradução Você será desconectado de todas as fontes Vibração @@ -215,7 +215,7 @@ Recente Outro armazenamento Tente reformular a consulta. - Prefira o leitor da direita para a esquerda (→) + Prefira o leitor da direita para a esquerda (←) Não disponível Tamanho: %s O que você ler será exibido aqui @@ -250,4 +250,19 @@ Padrão Tema dinâmico Aplica um tema criado no esquema de cores do seu papel de parede + Computando… + Importando mangá: %1$d de %2$d + Permitir + Bloquear no NSFW + Política de captura de tela + Sempre bloquear + Sugira mangá com base em suas preferências + Todos os dados são analisados localmente neste dispositivo. Não há transferência de seus dados pessoais para nenhum serviço + Comece a ler mangá e você receberá sugestões personalizadas + Sugestões + Ativar sugestões + Não sugira mangá NSFW + Habilitado + Desabilitado + Não foi possível carregar a lista de gêneros \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3155e92f0..5b75987f2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -249,8 +249,20 @@ Доступные источники Динамическая тема Применяет тему приложения, основанную на цветовой палитре обоев на устройстве - Разрешить скриншоты - Разрешить - Запретить для NSFW - Запретить всегда + Политика скриншотов + Разрешить + Запретить для NSFW + Всегда блокировать + Рекомендации + Включить рекомендации + Предлагать мангу на основе Ваших предпочтений + Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы + Начните читать мангу, чтобы получать персональные предложения + Не предлагать NSFW мангу + Включено + Выключено + Не удалось загрузить список жанров + Вычисления… + Создать проблему на GitHub + Импорт манги: %1$d из %2$d \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0982643a4..e4ac34869 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -204,7 +204,7 @@ Arama sonuçları Ağ bekleniyor… Parolayı tekrarla - Sağdan sola (→) okuyucuyu tercih et + Sağdan sola (←) okuyucuyu tercih et Denetleme Yanlış parola GitHub\'da sorun oluştur @@ -256,4 +256,14 @@ Uygunsuzlarda engelle Her zaman engelle İzin ver + Yeni bölümleri denetle + Öneriler + Önerileri etkinleştir + Tercihlerinize göre manga önerileri alın + Tüm veriler aygıt üzerinde yerel olarak işlenir. Kişisel verilerinizin herhangi bir hizmete aktarılması söz konusu değildir + Manga okumaya başladıktan sonra kişiselleştirilmiş öneriler alacaksınız + Uygunsuz manga önerme + Etkin + Devre dışı + Türler listesi yüklenemiyor \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a22b7a3a3..92792a51e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,7 +164,7 @@ Could not look for updates No updates available Right-to-left (←) - Prefer right-to-left (→) reader + Prefer right-to-left (←) reader Reading mode can be set up separately for each series New category Scale mode @@ -180,7 +180,7 @@ Restore from backup Restored Preparing… - Create issue on GitHub + Create issue on GitHub File not found All data was restored The data was restored, but there are errors @@ -251,10 +251,19 @@ Dynamic theme Applies a theme created on the color scheme of your wallpaper Importing manga: %1$d of %2$d - Screenshots policy + Screenshot policy Allow Block on NSFW - Block always + Always block + Suggestions + Enable suggestions + Suggest manga based on your preferences + All data is analyzed locally on this device. There is no transfer of your personal data to any services + Start reading manga and you will get personalized suggestions + Do not suggest NSFW manga + Enabled + Disabled + Unable to load genres list Never Only using WiFi Always diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml index 344a0cf14..0f4d7d6e3 100644 --- a/app/src/main/res/xml/pref_main.xml +++ b/app/src/main/res/xml/pref_main.xml @@ -61,6 +61,13 @@ app:allowDividerAbove="true" app:iconSpaceReserved="false" /> + + - + + + + + + + + + \ No newline at end of file