Compare commits

...

21 Commits

Author SHA1 Message Date
Koitharu d0ed1fb85f
Notify about broken source on list screen 7 months ago
Koitharu 9e5664da3a
Reorganize settings 7 months ago
Koitharu 35c158d35a
Update readme 7 months ago
Koitharu 464f24e9f0
Fix unwanted touch events when chapters sheet is collapsed 7 months ago
Koitharu c8a8203c39
Add authors to filter 7 months ago
Koitharu b414758f32
Improve saved filters 7 months ago
Koitharu 1181860e41
Improve saved filters 7 months ago
Koitharu e35521f16f
Fix code formatting 7 months ago
MuhamadSyabitHidayattulloh 5fb8ff53f9 Feat: Add Saved Filters Feature 7 months ago
Vicente a66283d035 Backup Restore reading stats 7 months ago
Vicente a1ba0b8c21 Backup scrobblings 7 months ago
Koitharu f3b42b9a42
Small improvement for chapter toast setting 7 months ago
google-labs-jules[bot] aa2f2c17fc feat(reader): Add setting to toggle chapter toast 7 months ago
Koitharu ebc17b645b
Fix build 7 months ago
Koitharu cc14e1abcf
Fix crash when fast go to background 7 months ago
Koitharu b1b474e2e7
Update readme 7 months ago
Koitharu 8ca3bece5d
Fix crashes 7 months ago
Frosted 90bd9023d5 Translated using Weblate (Turkish)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
7 months ago
Максим Горпиніч 986627f24d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
7 months ago
kota fujimi cf2b8e2481 Translated using Weblate (Japanese)
Currently translated at 76.4% (669 of 875 strings)

Co-authored-by: kota fujimi <urakids@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
7 months ago
Koitharu b9435de5cd
Update parsers 7 months ago

@ -4,7 +4,7 @@ root = true
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 4 indent_size = 4
indent_style = tab indent_style = space
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
tab_width = 4 tab_width = 4

3
.gitignore vendored

@ -6,6 +6,7 @@
/.idea/dictionaries /.idea/dictionaries
/.idea/modules.xml /.idea/modules.xml
/.idea/misc.xml /.idea/misc.xml
/.idea/markdown.xml
/.idea/discord.xml /.idea/discord.xml
/.idea/compiler.xml /.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml
@ -26,4 +27,4 @@
.cxx .cxx
/.idea/deviceManager.xml /.idea/deviceManager.xml
/.kotlin/ /.kotlin/
/.idea/AndroidProjectSystem.xml /.idea/AndroidProjectSystem.xml

@ -1,9 +1,7 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS"> <option name="OTHER_INDENT_OPTIONS">
<value> <value />
<option name="USE_TAB_CHARACTER" value="true" />
</value>
</option> </option>
<AndroidXmlCodeStyleSettings> <AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS"> <option name="LAYOUT_SETTINGS">
@ -22,40 +20,46 @@
</value> </value>
</option> </option>
</AndroidXmlCodeStyleSettings> </AndroidXmlCodeStyleSettings>
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" /> <option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script"> <codeStyleSettings language="Shell Script">
<indentOptions> <indentOptions>
<option name="USE_TAB_CHARACTER" value="true" /> <option name="USE_TAB_CHARACTER" value="true" />
@ -64,7 +68,6 @@
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
<indentOptions> <indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" /> <option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions> </indentOptions>
<arrangement> <arrangement>
<rules> <rules>
@ -179,9 +182,6 @@
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" /> <option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings> </codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

@ -35,7 +35,7 @@
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app * Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account * Automatically sync app data with other devices on the same account
* Support for older devices running Android 5.0+ * Support for older devices running Android 6.0+
</div> </div>
@ -112,6 +112,6 @@ You may copy, distribute and modify the software as long as you track changes/da
<div align="left"> <div align="left">
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser. The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
</div> </div>

@ -21,8 +21,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 23 minSdk = 23
targetSdk = 36 targetSdk = 36
versionCode = 1030 versionCode = 1031
versionName = '9.2' versionName = '9.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {

@ -26,7 +26,9 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
import org.koitharu.kotatsu.backups.data.model.HistoryBackup import org.koitharu.kotatsu.backups.data.model.HistoryBackup
import org.koitharu.kotatsu.backups.data.model.MangaBackup import org.koitharu.kotatsu.backups.data.model.MangaBackup
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
import org.koitharu.kotatsu.backups.data.model.SourceBackup import org.koitharu.kotatsu.backups.data.model.SourceBackup
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
import org.koitharu.kotatsu.backups.domain.BackupSection import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -109,6 +111,18 @@ class BackupRepository @Inject constructor(
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) }, data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(), serializer = serializer(),
) )
BackupSection.SCROBBLING -> output.writeJsonArray(
section = BackupSection.SCROBBLING,
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
serializer = serializer(),
)
BackupSection.STATS -> output.writeJsonArray(
section = BackupSection.STATS,
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
serializer = serializer(),
)
} }
progress?.emit(commonProgress) progress?.emit(commonProgress)
commonProgress++ commonProgress++
@ -163,6 +177,14 @@ class BackupRepository @Inject constructor(
getSourcesDao().upsert(it.toEntity()) getSourcesDao().upsert(it.toEntity())
} }
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
getScrobblingDao().upsert(it.toEntity())
}
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
getStatsDao().upsert(it.toEntity())
}
null -> CompositeResult.EMPTY // skip unknown entries null -> CompositeResult.EMPTY // skip unknown entries
} }
progress?.emit(commonProgress) progress?.emit(commonProgress)

@ -0,0 +1,40 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
@Serializable
class ScrobblingBackup(
@SerialName("scrobbler") val scrobbler: Int,
@SerialName("id") val id: Int,
@SerialName("manga_id") val mangaId: Long,
@SerialName("target_id") val targetId: Long,
@SerialName("status") val status: String?,
@SerialName("chapter") val chapter: Int,
@SerialName("comment") val comment: String?,
@SerialName("rating") val rating: Float,
) {
constructor(entity: ScrobblingEntity) : this(
scrobbler = entity.scrobbler,
id = entity.id,
mangaId = entity.mangaId,
targetId = entity.targetId,
status = entity.status,
chapter = entity.chapter,
comment = entity.comment,
rating = entity.rating,
)
fun toEntity() = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

@ -0,0 +1,28 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.stats.data.StatsEntity
@Serializable
class StatisticBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("started_at") val startedAt: Long,
@SerialName("duration") val duration: Long,
@SerialName("pages") val pages: Int,
) {
constructor(entity: StatsEntity) : this(
mangaId = entity.mangaId,
startedAt = entity.startedAt,
duration = entity.duration,
pages = entity.pages,
)
fun toEntity() = StatsEntity(
mangaId = mangaId,
startedAt = startedAt,
duration = duration,
pages = pages,
)
}

@ -15,6 +15,8 @@ enum class BackupSection(
SETTINGS_READER_GRID("reader_grid"), SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"), BOOKMARKS("bookmarks"),
SOURCES("sources"), SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
; ;
companion object { companion object {

@ -23,6 +23,8 @@ data class BackupSectionModel(
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
BackupSection.BOOKMARKS -> R.string.bookmarks BackupSection.BOOKMARKS -> R.string.bookmarks
BackupSection.SOURCES -> R.string.remote_sources BackupSection.SOURCES -> R.string.remote_sources
BackupSection.SCROBBLING -> R.string.tracking
BackupSection.STATS -> R.string.statistics
} }
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

@ -34,6 +34,9 @@ abstract class MangaDao {
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit") @Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List<String> abstract suspend fun findAuthors(query: String, limit: Int): List<String>
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.koitharu.kotatsu.parsers.model.MangaSource
object MangaSourceSerializer : KSerializer<MangaSource> {
override val descriptor: SerialDescriptor = serialDescriptor<String>()
override fun serialize(
encoder: Encoder,
value: MangaSource
) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
}

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.network.webview package org.koitharu.kotatsu.core.network.webview
import android.content.Context import android.content.Context
import android.util.AndroidRuntimeException
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@ -41,7 +42,13 @@ class WebViewExecutor @Inject constructor(
private val mutex = Mutex() private val mutex = Mutex()
val defaultUserAgent: String? by lazy { val defaultUserAgent: String? by lazy {
WebSettings.getDefaultUserAgent(context) try {
WebSettings.getDefaultUserAgent(context)
} catch (e: AndroidRuntimeException) {
e.printStackTraceDebug()
// Probably WebView is not available
null
}
} }
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock { suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {

@ -409,6 +409,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarTransparent: Boolean val isReaderBarTransparent: Boolean
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true) get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
val isReaderChapterToastEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
val isReaderKeepScreenOn: Boolean val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@ -747,6 +750,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC_SETTINGS = "sync_settings" const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent" const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"

@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
import java.io.File
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(
source.name.replace(File.separatorChar, '$'),
Context.MODE_PRIVATE,
)
var defaultSortOrder: SortOrder? var defaultSortOrder: SortOrder?
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)

@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.FrameLayout
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -15,54 +22,103 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
inline fun buildAlertDialog( inline fun buildAlertDialog(
@UiContext context: Context, @UiContext context: Context,
isCentered: Boolean = false, isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit, block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder( ): AlertDialog = MaterialAlertDialogBuilder(
context, context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0, if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
).apply(block).create() ).apply(block).create()
fun <B : AlertDialog.Builder> B.setCheckbox( fun <B : AlertDialog.Builder> B.setCheckbox(
@StringRes textResId: Int, @StringRes textResId: Int,
isChecked: Boolean, isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener onCheckedChangeListener: OnCheckedChangeListener
) = apply { ) = apply {
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context)) val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId) binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener) binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root) setView(binding.root)
} }
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList( fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>, list: List<T>,
delegate: AdapterDelegate<List<T>>, delegate: AdapterDelegate<List<T>>,
) = apply { ) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>() val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate) delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
} }
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList( fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>, list: List<T>,
vararg delegates: AdapterDelegate<List<T>>, vararg delegates: AdapterDelegate<List<T>>,
) = apply { ) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>() val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) } delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
} }
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply { fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
val recyclerView = RecyclerView(context) val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding( recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing), top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
) )
recyclerView.clipToPadding = false recyclerView.clipToPadding = false
recyclerView.adapter = adapter recyclerView.adapter = adapter
setView(recyclerView) setView(recyclerView)
}
fun <B : AlertDialog.Builder> B.setEditText(
inputType: Int,
singleLine: Boolean,
): EditText {
val editText = AppCompatEditText(context)
editText.inputType = inputType
if (singleLine) {
editText.setSingleLine()
editText.imeOptions = EditorInfo.IME_ACTION_DONE
}
val layout = FrameLayout(context)
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
lp.setMargins(
horizontalMargin,
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
horizontalMargin,
0,
)
layout.addView(editText, lp)
setView(layout)
return editText
}
fun <B : AlertDialog.Builder> B.setEditText(
entries: List<CharSequence>,
inputType: Int,
singleLine: Boolean,
): EditText {
if (entries.isEmpty()) {
return setEditText(inputType, singleLine)
}
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
binding.autoCompleteTextView.setAdapter(
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
)
binding.dropdown.setOnClickListener {
binding.autoCompleteTextView.showDropDown()
}
binding.autoCompleteTextView.inputType = inputType
if (singleLine) {
binding.autoCompleteTextView.setSingleLine()
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
}
setView(binding.root)
return binding.autoCompleteTextView
} }

@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
val data = it.tag val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data) onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
} }
private val chipOnLongClickListener = OnLongClickListener {
val chip = it as Chip
val data = it.tag
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
}
private val chipStyle: Int private val chipStyle: Int
private val iconsVisible: Boolean private val iconsVisible: Boolean
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
} }
var onChipCloseClickListener: OnChipCloseClickListener? = null var onChipCloseClickListener: OnChipCloseClickListener? = null
var onChipLongClickListener: OnChipLongClickListener? = null
init { init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
setOnCloseIconClickListener(chipOnCloseListener) setOnCloseIconClickListener(chipOnCloseListener)
setEnsureMinTouchTargetSize(false) setEnsureMinTouchTargetSize(false)
setOnClickListener(chipOnClickListener) setOnClickListener(chipOnClickListener)
setOnLongClickListener(chipOnLongClickListener)
isElegantTextHeight = false isElegantTextHeight = false
} }
@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?) fun onChipCloseClick(chip: Chip, data: Any?)
} }
fun interface OnChipLongClickListener {
fun onChipLongClick(chip: Chip, data: Any?): Boolean
}
} }

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
class TouchBlockLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
var isTouchEventsAllowed = true
override fun onInterceptTouchEvent(
ev: MotionEvent?
): Boolean = if (isTouchEventsAllowed) {
super.onInterceptTouchEvent(ev)
} else {
true
}
}

@ -99,10 +99,11 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onStateChanged(sheet: View, newState: Int) { override fun onStateChanged(sheet: View, newState: Int) {
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { val binding = viewBinding ?: return
return binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
} if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
val binding = viewBinding ?: return return
}
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -11,6 +12,7 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toCollection import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
@ -78,11 +80,20 @@ class ChaptersSelectionCallback(
ids.size == manga.chapters?.size -> viewModel.deleteLocal() ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> { else -> {
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet()) LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
Snackbar.make( try {
recyclerView, Snackbar.make(
R.string.chapters_will_removed_background, recyclerView,
Snackbar.LENGTH_LONG, R.string.chapters_will_removed_background,
).show() Snackbar.LENGTH_LONG,
).show()
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
Toast.makeText(
recyclerView.context,
R.string.chapters_will_removed_background,
Toast.LENGTH_SHORT,
).show()
}
} }
} }
mode?.finish() mode?.finish()

@ -0,0 +1,161 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.serializer
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.util.Locale
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
element<String?>("query", isOptional = true)
element(
elementName = "tags",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element(
elementName = "tagsExclude",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element<String?>("locale", isOptional = true)
element<String?>("originalLocale", isOptional = true)
element<Set<MangaState>>("states", isOptional = true)
element<Set<ContentRating>>("contentRating", isOptional = true)
element<Set<ContentType>>("types", isOptional = true)
element<Set<Demographic>>("demographics", isOptional = true)
element<Int>("year", isOptional = true)
element<Int>("yearFrom", isOptional = true)
element<Int>("yearTo", isOptional = true)
element<String?>("author", isOptional = true)
}
override fun serialize(
encoder: Encoder,
value: MangaListFilter
) = encoder.encodeStructure(descriptor) {
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
encodeIntElement(descriptor, 9, value.year)
encodeIntElement(descriptor, 10, value.yearFrom)
encodeIntElement(descriptor, 11, value.yearTo)
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
}
override fun deserialize(
decoder: Decoder
): MangaListFilter = decoder.decodeStructure(descriptor) {
var query: String? = MangaListFilter.EMPTY.query
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
var locale: Locale? = MangaListFilter.EMPTY.locale
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
var states: Set<MangaState> = MangaListFilter.EMPTY.states
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
var types: Set<ContentType> = MangaListFilter.EMPTY.types
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
var year: Int = MangaListFilter.EMPTY.year
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
var yearTo: Int = MangaListFilter.EMPTY.yearTo
var author: String? = MangaListFilter.EMPTY.author
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
4 -> originalLocale =
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
9 -> year = decodeIntElement(descriptor, 9)
10 -> yearFrom = decodeIntElement(descriptor, 10)
11 -> yearTo = decodeIntElement(descriptor, 11)
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
CompositeDecoder.DECODE_DONE -> break
}
}
MangaListFilter(
query = query,
tags = tags,
tagsExclude = tagsExclude,
locale = locale,
originalLocale = originalLocale,
states = states,
contentRating = contentRating,
types = types,
demographics = demographics,
year = year,
yearFrom = yearFrom,
yearTo = yearTo,
author = author,
)
}
private object MangaTagSerializer : KSerializer<MangaTag> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
element<String>("title")
element<String>("key")
element<String>("source")
}
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.title)
encodeStringElement(descriptor, 1, value.key)
encodeStringElement(descriptor, 2, value.source.name)
}
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
var title: String? = null
var key: String? = null
var source: String? = null
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> title = decodeStringElement(descriptor, 0)
1 -> key = decodeStringElement(descriptor, 1)
2 -> source = decodeStringElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break
}
}
MangaTag(
title = title ?: error("Missing 'title' field"),
key = key ?: error("Missing 'key' field"),
source = MangaSource(source),
)
}
}
}

@ -0,0 +1,30 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Serializable
@JsonIgnoreUnknownKeys
data class PersistableFilter(
@SerialName("name")
val name: String,
@Serializable(with = MangaSourceSerializer::class)
@SerialName("source")
val source: MangaSource,
@Serializable(with = MangaListFilterSerializer::class)
@SerialName("filter")
val filter: MangaListFilter,
) {
val id: Int
get() = name.hashCode()
companion object {
const val MAX_TITLE_LENGTH = 18
}
}

@ -0,0 +1,112 @@
package org.koitharu.kotatsu.filter.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.io.File
import javax.inject.Inject
@Reusable
class SavedFiltersRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
.onStart { emit(null) }
.map {
getAll(source)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
keys.mapNotNull { key ->
val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
Json.decodeFromString(value)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
}
suspend fun save(
source: MangaSource,
name: String,
filter: MangaListFilter,
): PersistableFilter = withContext(Dispatchers.Default) {
val persistableFilter = PersistableFilter(
name = name,
source = source,
filter = filter,
)
persist(source, persistableFilter)
persistableFilter
}
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val filter = load(source, id) ?: return@withContext
val newFilter = filter.copy(name = newName)
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
putString(key(newFilter.id), Json.encodeToString(newFilter))
}
newFilter
}
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
}
}
private fun persist(source: MangaSource, persistableFilter: PersistableFilter) {
val prefs = getPrefs(source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(key(persistableFilter.id), json)
}
}
private fun load(source: MangaSource, id: Int): PersistableFilter? {
val prefs = getPrefs(source)
val json = prefs.getString(key(id), null) ?: return null
return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
private fun getPrefs(source: MangaSource): SharedPreferences {
val key = source.name.replace(File.separatorChar, '$')
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
}
private companion object {
const val FILTER_PREFIX = "__pf_"
fun key(id: Int) = FILTER_PREFIX + id
}
}

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -25,6 +26,8 @@ import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -48,469 +51,502 @@ import javax.inject.Inject
@ViewModelScoped @ViewModelScoped
class FilterCoordinator @Inject constructor( class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle, private val savedFiltersRepository: SavedFiltersRepository,
lifecycle: ViewModelLifecycle,
) { ) {
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])) private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val sourceLocale = (repository.source as? MangaParserSource)?.locale private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY) private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder) private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() } private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
val capabilities = repository.filterCapabilities
val mangaSource: MangaSource
get() = repository.source val mangaSource: MangaSource
get() = repository.source
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty() val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null) val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty( val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
availableItems = availableSortOrders.sortedByOrdinal(), FilterProperty(
selectedItem = selected, availableItems = availableSortOrders.sortedByOrdinal(),
) selectedItem = selected,
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) )
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
getTopTags(TAGS_LIMIT), val tags: StateFlow<FilterProperty<MangaTag>> = combine(
currentListFilter.distinctUntilChangedBy { it.tags }, getTopTags(TAGS_LIMIT),
) { available, selected -> currentListFilter.distinctUntilChangedBy { it.tags },
available.fold( ) { available, selected ->
onSuccess = { available.fold(
FilterProperty( onSuccess = {
availableItems = it.addFirstDistinct(selected.tags), FilterProperty(
selectedItems = selected.tags, availableItems = it.addFirstDistinct(selected.tags),
) selectedItems = selected.tags,
}, )
onFailure = { },
FilterProperty.error(it) onFailure = {
}, FilterProperty.error(it)
) },
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) )
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine( val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
getBottomTags(TAGS_LIMIT), combine(
currentListFilter.distinctUntilChangedBy { it.tagsExclude }, getBottomTags(TAGS_LIMIT),
) { available, selected -> currentListFilter.distinctUntilChangedBy { it.tagsExclude },
available.fold( ) { available, selected ->
onSuccess = { available.fold(
FilterProperty( onSuccess = {
availableItems = it.addFirstDistinct(selected.tagsExclude), FilterProperty(
selectedItems = selected.tagsExclude, availableItems = it.addFirstDistinct(selected.tagsExclude),
) selectedItems = selected.tagsExclude,
}, )
onFailure = { },
FilterProperty.error(it) onFailure = {
}, FilterProperty.error(it)
) },
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) )
} else { }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
MutableStateFlow(FilterProperty.EMPTY) } else {
} MutableStateFlow(FilterProperty.EMPTY)
}
val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(), val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
currentListFilter.distinctUntilChangedBy { it.states }, combine(
) { available, selected -> flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
available.fold( currentListFilter.distinctUntilChangedBy { it.author },
onSuccess = { ) { available, selected ->
FilterProperty( FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(), availableItems = available,
selectedItems = selected.states, selectedItems = setOfNotNull(selected.author),
) )
}, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
onFailure = { } else {
FilterProperty.error(it) MutableStateFlow(FilterProperty.EMPTY)
}, }
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine( currentListFilter.distinctUntilChangedBy { it.states },
filterOptions.asFlow(), ) { available, selected ->
currentListFilter.distinctUntilChangedBy { it.contentRating }, available.fold(
) { available, selected -> onSuccess = {
available.fold( FilterProperty(
onSuccess = { availableItems = it.availableStates.sortedByOrdinal(),
FilterProperty( selectedItems = selected.states,
availableItems = it.availableContentRating.sortedByOrdinal(), )
selectedItems = selected.contentRating, },
) onFailure = {
}, FilterProperty.error(it)
onFailure = { },
FilterProperty.error(it) )
}, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
filterOptions.asFlow(),
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine( currentListFilter.distinctUntilChangedBy { it.contentRating },
filterOptions.asFlow(), ) { available, selected ->
currentListFilter.distinctUntilChangedBy { it.types }, available.fold(
) { available, selected -> onSuccess = {
available.fold( FilterProperty(
onSuccess = { availableItems = it.availableContentRating.sortedByOrdinal(),
FilterProperty( selectedItems = selected.contentRating,
availableItems = it.availableContentTypes.sortedByOrdinal(), )
selectedItems = selected.types, },
) onFailure = {
}, FilterProperty.error(it)
onFailure = { },
FilterProperty.error(it) )
}, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
filterOptions.asFlow(),
val demographics: StateFlow<FilterProperty<Demographic>> = combine( currentListFilter.distinctUntilChangedBy { it.types },
filterOptions.asFlow(), ) { available, selected ->
currentListFilter.distinctUntilChangedBy { it.demographics }, available.fold(
) { available, selected -> onSuccess = {
available.fold( FilterProperty(
onSuccess = { availableItems = it.availableContentTypes.sortedByOrdinal(),
FilterProperty( selectedItems = selected.types,
availableItems = it.availableDemographics.sortedByOrdinal(), )
selectedItems = selected.demographics, },
) onFailure = {
}, FilterProperty.error(it)
onFailure = { },
FilterProperty.error(it) )
}, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val demographics: StateFlow<FilterProperty<Demographic>> = combine(
filterOptions.asFlow(),
val locale: StateFlow<FilterProperty<Locale?>> = combine( currentListFilter.distinctUntilChangedBy { it.demographics },
filterOptions.asFlow(), ) { available, selected ->
currentListFilter.distinctUntilChangedBy { it.locale }, available.fold(
) { available, selected -> onSuccess = {
available.fold( FilterProperty(
onSuccess = { availableItems = it.availableDemographics.sortedByOrdinal(),
FilterProperty( selectedItems = selected.demographics,
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), )
selectedItems = setOfNotNull(selected.locale), },
) onFailure = {
}, FilterProperty.error(it)
onFailure = { },
FilterProperty.error(it) )
}, }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val locale: StateFlow<FilterProperty<Locale?>> = combine(
filterOptions.asFlow(),
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) { currentListFilter.distinctUntilChangedBy { it.locale },
combine( ) { available, selected ->
filterOptions.asFlow(), available.fold(
currentListFilter.distinctUntilChangedBy { it.originalLocale }, onSuccess = {
) { available, selected -> FilterProperty(
available.fold( availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
onSuccess = { selectedItems = setOfNotNull(selected.locale),
FilterProperty( )
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), },
selectedItems = setOfNotNull(selected.originalLocale), onFailure = {
) FilterProperty.error(it)
}, },
onFailure = { )
FilterProperty.error(it) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
},
) val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) combine(
} else { filterOptions.asFlow(),
MutableStateFlow(FilterProperty.EMPTY) currentListFilter.distinctUntilChangedBy { it.originalLocale },
} ) { available, selected ->
available.fold(
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) { onSuccess = {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected -> FilterProperty(
FilterProperty( availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
availableItems = listOf(YEAR_MIN, MAX_YEAR), selectedItems = setOfNotNull(selected.originalLocale),
selectedItems = setOf(selected.year), )
) },
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) onFailure = {
} else { FilterProperty.error(it)
MutableStateFlow(FilterProperty.EMPTY) },
} )
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) { } else {
currentListFilter.distinctUntilChanged { old, new -> MutableStateFlow(FilterProperty.EMPTY)
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom }
}.map { selected ->
FilterProperty( val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
availableItems = listOf(YEAR_MIN, MAX_YEAR), currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }), FilterProperty(
) availableItems = listOf(YEAR_MIN, MAX_YEAR),
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) selectedItems = setOf(selected.year),
} else { )
MutableStateFlow(FilterProperty.EMPTY) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} } else {
MutableStateFlow(FilterProperty.EMPTY)
fun reset() { }
currentListFilter.value = MangaListFilter.EMPTY
} val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
currentListFilter.distinctUntilChanged { old, new ->
fun snapshot() = Snapshot( old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
sortOrder = currentSortOrder.value, }.map { selected ->
listFilter = currentListFilter.value, FilterProperty(
) availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot) )
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
fun setSortOrder(newSortOrder: SortOrder) { } else {
currentSortOrder.value = newSortOrder MutableStateFlow(FilterProperty.EMPTY)
repository.defaultSortOrder = newSortOrder }
}
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
fun set(value: MangaListFilter) { savedFiltersRepository.observeAll(repository.source),
currentListFilter.value = value currentListFilter,
} ) { available, applied ->
FilterProperty(
fun setAdjusted(value: MangaListFilter) { availableItems = available,
var newFilter = value selectedItems = setOfNotNull(available.find { it.filter == applied }),
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) { )
newFilter = newFilter.copy( }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
query = newFilter.author,
author = null, fun reset() {
) currentListFilter.value = MangaListFilter.EMPTY
} }
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
newFilter = newFilter.copy( fun snapshot() = Snapshot(
query = null, sortOrder = currentSortOrder.value,
) listFilter = currentListFilter.value,
} )
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query) fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
}
set(newFilter) fun setSortOrder(newSortOrder: SortOrder) {
} currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
fun setQuery(value: String?) { }
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue -> fun set(value: MangaListFilter) {
if (capabilities.isSearchWithFiltersSupported || newQuery == null) { currentListFilter.value = value
oldValue.copy(query = newQuery) }
} else {
MangaListFilter(query = newQuery) fun setAdjusted(value: MangaListFilter) {
} var newFilter = value
} if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
} newFilter = newFilter.copy(
query = newFilter.author,
fun setLocale(value: Locale?) { author = null,
currentListFilter.update { oldValue -> )
oldValue.copy( }
locale = value, if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
query = oldValue.takeQueryIfSupported(), newFilter = MangaListFilter(query = newFilter.query)
) }
} set(newFilter)
} }
fun setAuthor(value: String?) { fun saveCurrentFilter(name: String) = coroutineScope.launch {
currentListFilter.update { oldValue -> savedFiltersRepository.save(repository.source, name, currentListFilter.value)
oldValue.copy( }
author = value,
query = oldValue.takeQueryIfSupported(), fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
) savedFiltersRepository.rename(repository.source, id, newName)
} }
}
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
fun setOriginalLocale(value: Locale?) { savedFiltersRepository.delete(repository.source, id)
currentListFilter.update { oldValue -> }
oldValue.copy(
originalLocale = value, fun setQuery(value: String?) {
query = oldValue.takeQueryIfSupported(), val newQuery = value?.trim()?.nullIfEmpty()
) currentListFilter.update { oldValue ->
} if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
} oldValue.copy(query = newQuery)
} else {
fun setYear(value: Int) { MangaListFilter(query = newQuery)
currentListFilter.update { oldValue -> }
oldValue.copy( }
year = value, }
query = oldValue.takeQueryIfSupported(),
) fun setLocale(value: Locale?) {
} currentListFilter.update { oldValue ->
} oldValue.copy(
locale = value,
fun setYearRange(valueFrom: Int, valueTo: Int) { query = oldValue.takeQueryIfSupported(),
currentListFilter.update { oldValue -> )
oldValue.copy( }
yearFrom = valueFrom, }
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(), fun setAuthor(value: String?) {
) currentListFilter.update { oldValue ->
} oldValue.copy(
} author = value,
query = oldValue.takeQueryIfSupported(),
fun toggleState(value: MangaState, isSelected: Boolean) { )
currentListFilter.update { oldValue -> }
oldValue.copy( }
states = if (isSelected) oldValue.states + value else oldValue.states - value,
query = oldValue.takeQueryIfSupported(), fun setOriginalLocale(value: Locale?) {
) currentListFilter.update { oldValue ->
} oldValue.copy(
} originalLocale = value,
query = oldValue.takeQueryIfSupported(),
fun toggleContentRating(value: ContentRating, isSelected: Boolean) { )
currentListFilter.update { oldValue -> }
oldValue.copy( }
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
query = oldValue.takeQueryIfSupported(), fun setYear(value: Int) {
) currentListFilter.update { oldValue ->
} oldValue.copy(
} year = value,
query = oldValue.takeQueryIfSupported(),
fun toggleDemographic(value: Demographic, isSelected: Boolean) { )
currentListFilter.update { oldValue -> }
oldValue.copy( }
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(), fun setYearRange(valueFrom: Int, valueTo: Int) {
) currentListFilter.update { oldValue ->
} oldValue.copy(
} yearFrom = valueFrom,
yearTo = valueTo,
fun toggleContentType(value: ContentType, isSelected: Boolean) { query = oldValue.takeQueryIfSupported(),
currentListFilter.update { oldValue -> )
oldValue.copy( }
types = if (isSelected) oldValue.types + value else oldValue.types - value, }
query = oldValue.takeQueryIfSupported(),
) fun toggleState(value: MangaState, isSelected: Boolean) {
} currentListFilter.update { oldValue ->
} oldValue.copy(
states = if (isSelected) oldValue.states + value else oldValue.states - value,
fun toggleTag(value: MangaTag, isSelected: Boolean) { query = oldValue.takeQueryIfSupported(),
currentListFilter.update { oldValue -> )
val newTags = if (capabilities.isMultipleTagsSupported) { }
if (isSelected) oldValue.tags + value else oldValue.tags - value }
} else {
if (isSelected) setOf(value) else emptySet() fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
} currentListFilter.update { oldValue ->
oldValue.copy( oldValue.copy(
tags = newTags, contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
tagsExclude = oldValue.tagsExclude - newTags, query = oldValue.takeQueryIfSupported(),
query = oldValue.takeQueryIfSupported(), )
) }
} }
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) { currentListFilter.update { oldValue ->
currentListFilter.update { oldValue -> oldValue.copy(
val newTagsExclude = if (capabilities.isMultipleTagsSupported) { demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value query = oldValue.takeQueryIfSupported(),
} else { )
if (isSelected) setOf(value) else emptySet() }
} }
oldValue.copy(
tags = oldValue.tags - newTagsExclude, fun toggleContentType(value: ContentType, isSelected: Boolean) {
tagsExclude = newTagsExclude, currentListFilter.update { oldValue ->
query = oldValue.takeQueryIfSupported(), oldValue.copy(
) types = if (isSelected) oldValue.types + value else oldValue.types - value,
} query = oldValue.takeQueryIfSupported(),
} )
}
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map { }
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
} fun toggleTag(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
private fun MangaListFilter.takeQueryIfSupported() = when { val newTags = if (capabilities.isMultipleTagsSupported) {
capabilities.isSearchWithFiltersSupported -> query if (isSelected) oldValue.tags + value else oldValue.tags - value
query.isNullOrEmpty() -> query } else {
hasNonSearchOptions() -> null if (isSelected) setOf(value) else emptySet()
else -> query }
} oldValue.copy(
tags = newTags,
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine( tagsExclude = oldValue.tagsExclude - newTags,
flow { emit(searchRepository.getTopTags(repository.source, limit)) }, query = oldValue.takeQueryIfSupported(),
filterOptions.asFlow(), )
) { suggested, options -> }
val all = options.getOrNull()?.availableTags.orEmpty() }
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit)) fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
if (result.size < limit) { currentListFilter.update { oldValue ->
result.addAll(all.shuffled().take(limit - result.size)) val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
} if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
if (result.isNotEmpty()) { } else {
Result.success(result) if (isSelected) setOf(value) else emptySet()
} else { }
options.map { result } oldValue.copy(
} tags = oldValue.tags - newTagsExclude,
}.catch { tagsExclude = newTagsExclude,
emit(Result.failure(it)) query = oldValue.takeQueryIfSupported(),
} )
}
private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine( }
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
filterOptions.asFlow(), fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
) { suggested, options -> it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
val all = options.getOrNull()?.availableTags.orEmpty() }
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit)) private fun MangaListFilter.takeQueryIfSupported() = when {
if (result.size < limit) { capabilities.isSearchWithFiltersSupported -> query
result.addAll(all.shuffled().take(limit - result.size)) query.isNullOrEmpty() -> query
} hasNonSearchOptions() -> null
if (result.isNotEmpty()) { else -> query
Result.success(result) }
} else {
options.map { result } private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
} flow { emit(searchRepository.getTopTags(repository.source, limit)) },
}.catch { filterOptions.asFlow(),
emit(Result.failure(it)) ) { suggested, options ->
} val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> { result.addAll(suggested.take(limit))
val result = ArrayDeque<T>(this.size + other.size) if (result.size < limit) {
result.addAll(this) result.addAll(all.shuffled().take(limit - result.size))
for (item in other) { }
if (item !in result) { if (result.isNotEmpty()) {
result.addFirst(item) Result.success(result)
} } else {
} options.map { result }
return result }
} }.catch {
emit(Result.failure(it))
private fun <T> List<T>.addFirstDistinct(item: T): List<T> { }
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this) private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
if (item !in result) { flow { emit(searchRepository.getRareTags(repository.source, limit)) },
result.addFirst(item) filterOptions.asFlow(),
} ) { suggested, options ->
return result val all = options.getOrNull()?.availableTags.orEmpty()
} val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
data class Snapshot( if (result.size < limit) {
val sortOrder: SortOrder, result.addAll(all.shuffled().take(limit - result.size))
val listFilter: MangaListFilter, }
) if (result.isNotEmpty()) {
Result.success(result)
interface Owner { } else {
options.map { result }
val filterCoordinator: FilterCoordinator }
} }.catch {
emit(Result.failure(it))
companion object { }
private const val TAGS_LIMIT = 12 private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
fun find(fragment: Fragment): FilterCoordinator? { for (item in other) {
(fragment.activity as? Owner)?.let { if (item !in result) {
return it.filterCoordinator result.addFirst(item)
} }
var f = fragment }
while (true) { return result
(f as? Owner)?.let { }
return it.filterCoordinator
} private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
f = f.parentFragment ?: break val result = ArrayDeque<T>(this.size + 1)
} result.addAll(this)
return null if (item !in result) {
} result.addFirst(item)
}
fun require(fragment: Fragment): FilterCoordinator { return result
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found") }
}
} data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
interface Owner {
val filterCoordinator: FilterCoordinator
}
companion object {
private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let {
return it.filterCoordinator
}
var f = fragment
while (true) {
(f as? Owner)?.let {
return it.filterCoordinator
}
f = f.parentFragment ?: break
}
return null
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
} }

@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
@ -28,69 +29,75 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener, class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
ChipsView.OnChipCloseClickListener { ChipsView.OnChipCloseClickListener {
@Inject @Inject
lateinit var filterHeaderProducer: FilterHeaderProducer lateinit var filterHeaderProducer: FilterHeaderProducer
private val filter: FilterCoordinator private val filter: FilterCoordinator
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false) return FragmentFilterHeaderBinding.inflate(inflater, container, false)
} }
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
binding.chipsTags.onChipCloseClickListener = this binding.chipsTags.onChipCloseClickListener = this
filterHeaderProducer.observeHeader(filter) filterHeaderProducer.observeHeader(filter)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, ::onDataChanged) .observe(viewLifecycleOwner, ::onDataChanged)
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
when (data) { when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked) is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is String -> Unit is PersistableFilter -> if (chip.isChecked) {
null -> router.showTagsCatalogSheet(excludeMode = false) filter.reset()
} } else {
} filter.setAdjusted(data.filter)
}
override fun onChipCloseClick(chip: Chip, data: Any?) { is String -> Unit
when (data) { null -> router.showTagsCatalogSheet(excludeMode = false)
is String -> if (data == filter.snapshot().listFilter.author) { }
filter.setAuthor(null) }
} else {
filter.setQuery(null)
}
is ContentRating -> filter.toggleContentRating(data, false) override fun onChipCloseClick(chip: Chip, data: Any?) {
is Demographic -> filter.toggleDemographic(data, false) when (data) {
is ContentType -> filter.toggleContentType(data, false) is String -> if (data == filter.snapshot().listFilter.author) {
is MangaState -> filter.toggleState(data, false) filter.setAuthor(null)
is Locale -> filter.setLocale(null) } else {
is Int -> filter.setYear(YEAR_UNKNOWN) filter.setQuery(null)
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN) }
}
}
private fun onDataChanged(header: FilterHeaderModel) { is ContentRating -> filter.toggleContentRating(data, false)
val binding = viewBinding ?: return is Demographic -> filter.toggleDemographic(data, false)
val chips = header.chips is ContentType -> filter.toggleContentType(data, false)
if (chips.isEmpty()) { is MangaState -> filter.toggleState(data, false)
binding.chipsTags.setChips(emptyList()) is Locale -> filter.setLocale(null)
binding.root.isVisible = false is Int -> filter.setYear(YEAR_UNKNOWN)
return is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
} }
binding.chipsTags.setChips(header.chips) }
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) { private fun onDataChanged(header: FilterHeaderModel) {
binding.scrollView.smoothScrollTo(0, 0) val binding = viewBinding ?: return
} else { val chips = header.chips
binding.scrollView.scrollTo(0, 0) if (chips.isEmpty()) {
} binding.chipsTags.setChips(emptyList())
} binding.root.isVisible = false
return
}
binding.chipsTags.setChips(header.chips)
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
}
} }

@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -17,143 +18,162 @@ import javax.inject.Inject
import androidx.appcompat.R as appcompatR import androidx.appcompat.R as appcompatR
class FilterHeaderProducer @Inject constructor( class FilterHeaderProducer @Inject constructor(
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
) { ) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> { fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot -> return combine(
val chipList = createChipsList( filterCoordinator.savedFilters,
source = filterCoordinator.mangaSource, filterCoordinator.tags,
capabilities = filterCoordinator.capabilities, filterCoordinator.observe(),
tagsProperty = tags, ) { saved, tags, snapshot ->
snapshot = snapshot.listFilter, val chipList = createChipsList(
limit = 12, source = filterCoordinator.mangaSource,
) capabilities = filterCoordinator.capabilities,
FilterHeaderModel( savedFilters = saved,
chips = chipList, tagsProperty = tags,
sortOrder = snapshot.sortOrder, snapshot = snapshot.listFilter,
isFilterApplied = !snapshot.listFilter.isEmpty(), limit = 12,
) )
} FilterHeaderModel(
} chips = chipList,
sortOrder = snapshot.sortOrder,
isFilterApplied = !snapshot.listFilter.isEmpty(),
)
}
}
private suspend fun createChipsList( private suspend fun createChipsList(
source: MangaSource, source: MangaSource,
capabilities: MangaListFilterCapabilities, capabilities: MangaListFilterCapabilities,
tagsProperty: FilterProperty<MangaTag>, savedFilters: FilterProperty<PersistableFilter>,
snapshot: MangaListFilter, tagsProperty: FilterProperty<MangaTag>,
limit: Int, snapshot: MangaListFilter,
): List<ChipsView.ChipModel> { limit: Int,
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3) ): List<ChipsView.ChipModel> {
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
val selectedTags = tagsProperty.selectedItems.toMutableSet() if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
var tags = if (selectedTags.isEmpty()) { val selectedTags = tagsProperty.selectedItems.toMutableSet()
searchRepository.getTagsSuggestion("", limit, source) var tags = if (selectedTags.isEmpty()) {
} else { searchRepository.getTagsSuggestion("", limit, source)
searchRepository.getTagsSuggestion(selectedTags).take(limit) } else {
} searchRepository.getTagsSuggestion(selectedTags).take(limit)
if (tags.size < limit) { }
tags = tags + tagsProperty.availableItems.take(limit - tags.size) if (tags.size < limit) {
} tags = tags + tagsProperty.availableItems.take(limit - tags.size)
if (tags.isEmpty() && selectedTags.isEmpty()) { }
return emptyList() if (tags.isEmpty() && selectedTags.isEmpty()) {
} return emptyList()
for (tag in tags) { }
val model = ChipsView.ChipModel( for (saved in savedFilters.availableItems) {
title = tag.title, val model = ChipsView.ChipModel(
isChecked = selectedTags.remove(tag), title = saved.name,
data = tag, isChecked = saved in savedFilters.selectedItems,
) data = saved,
if (model.isChecked) { )
result.addFirst(model) if (model.isChecked) {
} else { selectedTags.removeAll(saved.filter.tags)
result.addLast(model) result.addFirst(model)
} } else {
} result.addLast(model)
for (tag in selectedTags) { }
val model = ChipsView.ChipModel( }
title = tag.title, for (tag in tags) {
isChecked = true, val model = ChipsView.ChipModel(
data = tag, title = tag.title,
) isChecked = selectedTags.remove(tag),
result.addFirst(model) data = tag,
} )
} if (model.isChecked) {
snapshot.locale?.let { result.addFirst(model)
result.addFirst( } else {
ChipsView.ChipModel( result.addLast(model)
title = it.getDisplayName(it).toTitleCase(it), }
icon = R.drawable.ic_language, }
isCloseable = true, for (tag in selectedTags) {
data = it, val model = ChipsView.ChipModel(
), title = tag.title,
) isChecked = true,
} data = tag,
snapshot.types.forEach { )
result.addFirst( result.addFirst(model)
ChipsView.ChipModel( }
titleResId = it.titleResId, }
isCloseable = true, snapshot.locale?.let {
data = it, result.addFirst(
), ChipsView.ChipModel(
) title = it.getDisplayName(it).toTitleCase(it),
} icon = R.drawable.ic_language,
snapshot.demographics.forEach { isCloseable = true,
result.addFirst( data = it,
ChipsView.ChipModel( ),
titleResId = it.titleResId, )
isCloseable = true, }
data = it, snapshot.types.forEach {
), result.addFirst(
) ChipsView.ChipModel(
} titleResId = it.titleResId,
snapshot.contentRating.forEach { isCloseable = true,
result.addFirst( data = it,
ChipsView.ChipModel( ),
titleResId = it.titleResId, )
isCloseable = true, }
data = it, snapshot.demographics.forEach {
), result.addFirst(
) ChipsView.ChipModel(
} titleResId = it.titleResId,
snapshot.states.forEach { isCloseable = true,
result.addFirst( data = it,
ChipsView.ChipModel( ),
titleResId = it.titleResId, )
isCloseable = true, }
data = it, snapshot.contentRating.forEach {
), result.addFirst(
) ChipsView.ChipModel(
} titleResId = it.titleResId,
if (!snapshot.query.isNullOrEmpty()) { isCloseable = true,
result.addFirst( data = it,
ChipsView.ChipModel( ),
title = snapshot.query, )
icon = appcompatR.drawable.abc_ic_search_api_material, }
isCloseable = true, snapshot.states.forEach {
data = snapshot.query, result.addFirst(
), ChipsView.ChipModel(
) titleResId = it.titleResId,
} isCloseable = true,
if (!snapshot.author.isNullOrEmpty()) { data = it,
result.addFirst( ),
ChipsView.ChipModel( )
title = snapshot.author, }
icon = R.drawable.ic_user, if (!snapshot.query.isNullOrEmpty()) {
isCloseable = true, result.addFirst(
data = snapshot.author, ChipsView.ChipModel(
), title = snapshot.query,
) icon = appcompatR.drawable.abc_ic_search_api_material,
} isCloseable = true,
val hasTags = result.any { it.data is MangaTag } data = snapshot.query,
if (hasTags) { ),
result.addFirst(moreTagsChip()) )
} }
return result if (!snapshot.author.isNullOrEmpty()) {
} result.addFirst(
ChipsView.ChipModel(
title = snapshot.author,
icon = R.drawable.ic_user,
isCloseable = true,
data = snapshot.author,
),
)
}
val hasTags = result.any { it.data is MangaTag }
if (hasTags) {
result.addFirst(moreTagsChip())
}
return result
}
private fun moreTagsChip() = ChipsView.ChipModel( private fun moreTagsChip() = ChipsView.ChipModel(
titleResId = R.string.genres, titleResId = R.string.genres,
icon = R.drawable.ic_drawer_menu_open, icon = R.drawable.ic_drawer_menu_open,
) )
} }

@ -1,23 +1,36 @@
package org.koitharu.kotatsu.filter.ui.sheet package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Bundle import android.os.Bundle
import android.text.InputFilter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setEditText
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
@ -26,6 +39,8 @@ import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -35,322 +50,488 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale import java.util.Locale
import java.util.TreeSet
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(), class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener, AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener { View.OnClickListener,
ChipsView.OnChipClickListener,
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { ChipsView.OnChipLongClickListener,
return SheetFilterBinding.inflate(inflater, container, false) ChipsView.OnChipCloseClickListener {
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { return SheetFilterBinding.inflate(inflater, container, false)
super.onViewBindingCreated(binding, savedInstanceState) }
if (dialog == null) {
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom) override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
binding.scrollView.scrollIndicators = 0 super.onViewBindingCreated(binding, savedInstanceState)
} if (dialog == null) {
val filter = FilterCoordinator.require(this) binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) binding.scrollView.scrollIndicators = 0
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) }
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged) val filter = FilterCoordinator.require(this)
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.states.observe(viewLifecycleOwner, this::onStateChanged) filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged) filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged) filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
binding.layoutGenres.setTitle( filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
if (filter.capabilities.isMultipleTagsSupported) { filter.year.observe(viewLifecycleOwner, this::onYearChanged)
R.string.genres filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
} else { filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
R.string.genre
}, binding.layoutGenres.setTitle(
) if (filter.capabilities.isMultipleTagsSupported) {
binding.spinnerLocale.onItemSelectedListener = this R.string.genres
binding.spinnerOriginalLocale.onItemSelectedListener = this } else {
binding.spinnerOrder.onItemSelectedListener = this R.string.genre
binding.chipsState.onChipClickListener = this },
binding.chipsTypes.onChipClickListener = this )
binding.chipsContentRating.onChipClickListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.chipsDemographics.onChipClickListener = this binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.chipsGenres.onChipClickListener = this binding.spinnerOrder.onItemSelectedListener = this
binding.chipsGenresExclude.onChipClickListener = this binding.chipsSavedFilters.onChipClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.chipsState.onChipClickListener = this
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.chipsTypes.onChipClickListener = this
binding.layoutGenres.setOnMoreButtonClickListener { binding.chipsContentRating.onChipClickListener = this
router.showTagsCatalogSheet(excludeMode = false) binding.chipsDemographics.onChipClickListener = this
} binding.chipsGenres.onChipClickListener = this
binding.layoutGenresExclude.setOnMoreButtonClickListener { binding.chipsGenresExclude.onChipClickListener = this
router.showTagsCatalogSheet(excludeMode = true) binding.chipsAuthor.onChipClickListener = this
} binding.chipsSavedFilters.onChipLongClickListener = this
} binding.chipsSavedFilters.onChipCloseClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
val typeMask = WindowInsetsCompat.Type.systemBars() binding.layoutGenres.setOnMoreButtonClickListener {
viewBinding?.scrollView?.updatePadding( router.showTagsCatalogSheet(excludeMode = false)
bottom = insets.getInsets(typeMask).bottom, }
) binding.layoutGenresExclude.setOnMoreButtonClickListener {
return insets.consume(v, typeMask, bottom = true) router.showTagsCatalogSheet(excludeMode = true)
} }
combine(
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
val filter = FilterCoordinator.require(this) filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
when (parent.id) { Boolean::and,
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position]) ).flowOn(Dispatchers.Default)
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) .observe(viewLifecycleOwner) {
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position]) binding.buttonSave.isEnabled = it
} }
} binding.buttonSave.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
override fun onNothingSelected(parent: AdapterView<*>?) = Unit }
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
if (!fromUser) { val typeMask = WindowInsetsCompat.Type.systemBars()
return viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
} bottomMargin = insets.getInsets(typeMask).bottom
val intValue = value.toInt() }
val filter = FilterCoordinator.require(this) return insets.consume(v, typeMask, bottom = true)
when (slider.id) { }
R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) { override fun onClick(v: View) {
YEAR_UNKNOWN when (v.id) {
} else { R.id.button_done -> dismiss()
intValue R.id.button_save -> onSaveFilterClick("")
}, }
) }
}
} override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = FilterCoordinator.require(this)
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) { when (parent.id) {
if (!fromUser) { R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
return R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
} R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
val filter = FilterCoordinator.require(this) }
when (slider.id) { }
R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let { override fun onNothingSelected(parent: AdapterView<*>?) = Unit
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN, private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
valueTo = slider.values.lastOrNull()?.let { if (!fromUser) {
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt() return
} ?: YEAR_UNKNOWN, }
) val intValue = value.toInt()
} val filter = FilterCoordinator.require(this)
} when (slider.id) {
R.id.slider_year -> filter.setYear(
override fun onChipClick(chip: Chip, data: Any?) { if (intValue <= slider.valueFrom.toIntUp()) {
val filter = FilterCoordinator.require(this) YEAR_UNKNOWN
when (data) { } else {
is MangaState -> filter.toggleState(data, !chip.isChecked) intValue
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { },
filter.toggleTagExclude(data, !chip.isChecked) )
} else { }
filter.toggleTag(data, !chip.isChecked) }
}
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
is ContentType -> filter.toggleContentType(data, !chip.isChecked) if (!fromUser) {
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) return
is Demographic -> filter.toggleDemographic(data, !chip.isChecked) }
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) val filter = FilterCoordinator.require(this)
} when (slider.id) {
} R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let {
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) { if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
val b = viewBinding ?: return } ?: YEAR_UNKNOWN,
b.layoutOrder.isGone = value.isEmpty() valueTo = slider.values.lastOrNull()?.let {
if (value.isEmpty()) { if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
return } ?: YEAR_UNKNOWN,
} )
val selected = value.selectedItems.single() }
b.spinnerOrder.adapter = ArrayAdapter( }
b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item, override fun onChipClick(chip: Chip, data: Any?) {
android.R.id.text1, val filter = FilterCoordinator.require(this)
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, when (data) {
) is MangaState -> filter.toggleState(data, !chip.isChecked)
val selectedIndex = value.availableItems.indexOf(selected) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
if (selectedIndex >= 0) { filter.toggleTagExclude(data, !chip.isChecked)
b.spinnerOrder.setSelection(selectedIndex, false) } else {
} filter.toggleTag(data, !chip.isChecked)
} }
private fun onLocaleChanged(value: FilterProperty<Locale?>) { is ContentType -> filter.toggleContentType(data, !chip.isChecked)
val b = viewBinding ?: return is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
b.layoutLocale.isGone = value.isEmpty() is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
if (value.isEmpty()) { is PersistableFilter -> filter.setAdjusted(data.filter)
return is String -> if (chip.isChecked) {
} filter.setAuthor(null)
val selected = value.selectedItems.singleOrNull() } else {
b.spinnerLocale.adapter = ArrayAdapter( filter.setAuthor(data)
b.spinnerLocale.context, }
android.R.layout.simple_spinner_dropdown_item, null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
android.R.id.text1, }
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) }, }
)
val selectedIndex = value.availableItems.indexOf(selected) override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
if (selectedIndex >= 0) { return when (data) {
b.spinnerLocale.setSelection(selectedIndex, false) is PersistableFilter -> {
} showSavedFilterMenu(chip, data)
} true
}
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return else -> false
b.layoutOriginalLocale.isGone = value.isEmpty() }
if (value.isEmpty()) { }
return
} override fun onChipCloseClick(chip: Chip, data: Any?) {
val selected = value.selectedItems.singleOrNull() when (data) {
b.spinnerOriginalLocale.adapter = ArrayAdapter( is PersistableFilter -> {
b.spinnerOriginalLocale.context, showSavedFilterMenu(chip, data)
android.R.layout.simple_spinner_dropdown_item, }
android.R.id.text1, }
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) }, }
)
val selectedIndex = value.availableItems.indexOf(selected) private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
if (selectedIndex >= 0) { val b = viewBinding ?: return
b.spinnerOriginalLocale.setSelection(selectedIndex, false) b.layoutOrder.isGone = value.isEmpty()
} if (value.isEmpty()) {
} return
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) { val selected = value.selectedItems.single()
val b = viewBinding ?: return b.spinnerOrder.adapter = ArrayAdapter(
b.layoutGenres.isGone = value.isEmptyAndSuccess() b.spinnerOrder.context,
b.layoutGenres.setError(value.error?.getDisplayMessage(resources)) android.R.layout.simple_spinner_dropdown_item,
if (value.isEmpty()) { android.R.id.text1,
return value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
} )
val chips = value.availableItems.map { tag -> val selectedIndex = value.availableItems.indexOf(selected)
ChipsView.ChipModel( if (selectedIndex >= 0) {
title = tag.title, b.spinnerOrder.setSelection(selectedIndex, false)
isChecked = tag in value.selectedItems, }
data = tag, }
)
} private fun onLocaleChanged(value: FilterProperty<Locale?>) {
b.chipsGenres.setChips(chips) val b = viewBinding ?: return
} b.layoutLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) { return
val b = viewBinding ?: return }
b.layoutGenresExclude.isGone = value.isEmpty() val selected = value.selectedItems.singleOrNull()
if (value.isEmpty()) { b.spinnerLocale.adapter = ArrayAdapter(
return b.spinnerLocale.context,
} android.R.layout.simple_spinner_dropdown_item,
val chips = value.availableItems.map { tag -> android.R.id.text1,
ChipsView.ChipModel( value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
title = tag.title, )
isChecked = tag in value.selectedItems, val selectedIndex = value.availableItems.indexOf(selected)
data = tag, if (selectedIndex >= 0) {
) b.spinnerLocale.setSelection(selectedIndex, false)
} }
b.chipsGenresExclude.setChips(chips) }
}
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
private fun onStateChanged(value: FilterProperty<MangaState>) { val b = viewBinding ?: return
val b = viewBinding ?: return b.layoutOriginalLocale.isGone = value.isEmpty()
b.layoutState.isGone = value.isEmpty() if (value.isEmpty()) {
if (value.isEmpty()) { return
return }
} val selected = value.selectedItems.singleOrNull()
val chips = value.availableItems.map { state -> b.spinnerOriginalLocale.adapter = ArrayAdapter(
ChipsView.ChipModel( b.spinnerOriginalLocale.context,
title = getString(state.titleResId), android.R.layout.simple_spinner_dropdown_item,
isChecked = state in value.selectedItems, android.R.id.text1,
data = state, value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
) )
} val selectedIndex = value.availableItems.indexOf(selected)
b.chipsState.setChips(chips) if (selectedIndex >= 0) {
} b.spinnerOriginalLocale.setSelection(selectedIndex, false)
}
private fun onContentTypesChanged(value: FilterProperty<ContentType>) { }
val b = viewBinding ?: return
b.layoutTypes.isGone = value.isEmpty() private fun onTagsChanged(value: FilterProperty<MangaTag>) {
if (value.isEmpty()) { val b = viewBinding ?: return
return b.layoutGenres.isGone = value.isEmptyAndSuccess()
} b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
val chips = value.availableItems.map { type -> if (value.isEmpty()) {
ChipsView.ChipModel( return
title = getString(type.titleResId), }
isChecked = type in value.selectedItems, val chips = value.availableItems.map { tag ->
data = type, ChipsView.ChipModel(
) title = tag.title,
} isChecked = tag in value.selectedItems,
b.chipsTypes.setChips(chips) data = tag,
} )
}
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) { b.chipsGenres.setChips(chips)
val b = viewBinding ?: return }
b.layoutContentRating.isGone = value.isEmpty()
if (value.isEmpty()) { private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
return val b = viewBinding ?: return
} b.layoutGenresExclude.isGone = value.isEmpty()
val chips = value.availableItems.map { contentRating -> if (value.isEmpty()) {
ChipsView.ChipModel( return
title = getString(contentRating.titleResId), }
isChecked = contentRating in value.selectedItems, val chips = value.availableItems.map { tag ->
data = contentRating, ChipsView.ChipModel(
) title = tag.title,
} isChecked = tag in value.selectedItems,
b.chipsContentRating.setChips(chips) data = tag,
} )
}
private fun onDemographicsChanged(value: FilterProperty<Demographic>) { b.chipsGenresExclude.setChips(chips)
val b = viewBinding ?: return }
b.layoutDemographics.isGone = value.isEmpty()
if (value.isEmpty()) { private fun onAuthorsChanged(value: FilterProperty<String>) {
return val b = viewBinding ?: return
} b.layoutAuthor.isGone = value.isEmpty()
val chips = value.availableItems.map { demographic -> if (value.isEmpty()) {
ChipsView.ChipModel( return
title = getString(demographic.titleResId), }
isChecked = demographic in value.selectedItems, val chips = value.availableItems.map { author ->
data = demographic, ChipsView.ChipModel(
) title = author,
} isChecked = author in value.selectedItems,
b.chipsDemographics.setChips(chips) data = author,
} )
}
private fun onYearChanged(value: FilterProperty<Int>) { b.chipsAuthor.setChips(chips)
val b = viewBinding ?: return }
b.layoutYear.isGone = value.isEmpty()
if (value.isEmpty()) { private fun onStateChanged(value: FilterProperty<MangaState>) {
return val b = viewBinding ?: return
} b.layoutState.isGone = value.isEmpty()
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN if (value.isEmpty()) {
b.layoutYear.setValueText( return
if (currentValue == YEAR_UNKNOWN) { }
getString(R.string.any) val chips = value.availableItems.map { state ->
} else { ChipsView.ChipModel(
currentValue.toString() title = getString(state.titleResId),
}, isChecked = state in value.selectedItems,
) data = state,
b.sliderYear.valueFrom = value.availableItems.first().toFloat() )
b.sliderYear.valueTo = value.availableItems.last().toFloat() }
b.sliderYear.setValueRounded(currentValue.toFloat()) b.chipsState.setChips(chips)
} }
private fun onYearRangeChanged(value: FilterProperty<Int>) { private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutYearsRange.isGone = value.isEmpty() b.layoutTypes.isGone = value.isEmpty()
if (value.isEmpty()) { if (value.isEmpty()) {
return return
} }
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat() val chips = value.availableItems.map { type ->
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat() ChipsView.ChipModel(
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom title = getString(type.titleResId),
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo isChecked = type in value.selectedItems,
b.layoutYearsRange.setValueText( data = type,
getString( )
R.string.memory_usage_pattern, }
currentValueFrom.toInt().toString(), b.chipsTypes.setChips(chips)
currentValueTo.toInt().toString(), }
),
) private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) val b = viewBinding ?: return
} b.layoutContentRating.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel(
title = getString(contentRating.titleResId),
isChecked = contentRating in value.selectedItems,
data = contentRating,
)
}
b.chipsContentRating.setChips(chips)
}
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
val b = viewBinding ?: return
b.layoutDemographics.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { demographic ->
ChipsView.ChipModel(
title = getString(demographic.titleResId),
isChecked = demographic in value.selectedItems,
data = demographic,
)
}
b.chipsDemographics.setChips(chips)
}
private fun onYearChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYear.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
b.layoutYear.setValueText(
if (currentValue == YEAR_UNKNOWN) {
getString(R.string.any)
} else {
currentValue.toString()
},
)
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
b.sliderYear.valueTo = value.availableItems.last().toFloat()
b.sliderYear.setValueRounded(currentValue.toFloat())
}
private fun onYearRangeChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYearsRange.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
b.layoutYearsRange.setValueText(
getString(
R.string.memory_usage_pattern,
currentValueFrom.toInt().toString(),
currentValueTo.toInt().toString(),
),
)
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
}
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
val b = viewBinding ?: return
b.layoutSavedFilters.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { f ->
ChipsView.ChipModel(
title = f.name,
isChecked = f in value.selectedItems,
data = f,
isDropdown = true,
)
}
b.chipsSavedFilters.setChips(chips)
}
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
val menu = PopupMenu(context ?: return, anchor)
val filter = FilterCoordinator.require(this)
menu.inflate(R.menu.popup_saved_filter)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
R.id.action_rename -> onRenameFilterClick(preset)
}
true
}
menu.show()
}
private fun onSaveFilterClick(name: String) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
buildAlertDialog(context ?: return) {
val input = setEditText(
entries = existingNames.toList(),
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.setHint(R.string.enter_name)
input.setText(name)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
setTitle(R.string.save_filter)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty()) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
onSaveFilterClick("")
} else if (text in existingNames) {
askForFilterOverwrite(filter, text)
} else {
filter.saveCurrentFilter(text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun onRenameFilterClick(preset: PersistableFilter) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
input.setHint(R.string.enter_name)
input.setText(preset.name)
setTitle(R.string.rename)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty() || text in existingNames) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
} else {
filter.renameSavedFilter(preset.id, text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
buildAlertDialog(context ?: return) {
setTitle(R.string.save_filter)
setMessage(getString(R.string.filter_overwrite_confirm, name))
setPositiveButton(R.string.overwrite) { _, _ ->
filter.saveCurrentFilter(name)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
onSaveFilterClick(name)
}
}.show()
}
} }

@ -1,6 +1,8 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.Manifest import android.Manifest
import android.app.BackgroundServiceStartNotAllowedException
import android.app.ServiceStartNotAllowedException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
@ -58,6 +60,7 @@ import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
@ -288,7 +291,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustFabVisibility(isResumeEnabled = isEnabled) adjustFabVisibility(isResumeEnabled = isEnabled)
} }
private fun onFirstStart() { private fun onFirstStart() = try {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
LocalStorageCleanupWorker.enqueue(applicationContext) LocalStorageCleanupWorker.enqueue(applicationContext)
@ -303,6 +306,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
} }
} }
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
} }
private fun adjustAppbar(topFragment: Fragment) { private fun adjustAppbar(topFragment: Fragment) {

@ -488,7 +488,11 @@ class ReaderActivity :
uiState.incognito -> getString(R.string.incognito_mode) uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle else -> chapterTitle
} }
if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) { if (
settings.isReaderChapterToastEnabled &&
chapterTitle != previous?.getChapterTitle(resources) &&
chapterTitle.isNotEmpty()
) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION) viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
} }
if (uiState.isSliderAvailable()) { if (uiState.isSliderAvailable()) {

@ -29,120 +29,133 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchKind
@AndroidEntryPoint @AndroidEntryPoint
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner, View.OnClickListener {
override val viewModel by viewModels<RemoteListViewModel>() override val viewModel by viewModels<RemoteListViewModel>()
override val filterCoordinator: FilterCoordinator override val filterCoordinator: FilterCoordinator
get() = viewModel.filterCoordinator get() = viewModel.filterCoordinator
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(RemoteListMenuProvider()) addMenuProvider(RemoteListMenuProvider())
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) } viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() }
.drop(1) filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
.observe(viewLifecycleOwner) { .drop(1)
activity?.invalidateMenu() .observe(viewLifecycleOwner) {
} activity?.invalidateMenu()
} }
}
override fun onScrolledToEnd() {
viewModel.loadNextPage() override fun onScrolledToEnd() {
} viewModel.loadNextPage()
}
override fun onCreateActionMode(
controller: ListSelectionController, override fun onCreateActionMode(
menuInflater: MenuInflater, controller: ListSelectionController,
menu: Menu menuInflater: MenuInflater,
): Boolean { menu: Menu
menuInflater.inflate(R.menu.mode_remote, menu) ): Boolean {
return super.onCreateActionMode(controller, menuInflater, menu) menuInflater.inflate(R.menu.mode_remote, menu)
} return super.onCreateActionMode(controller, menuInflater, menu)
}
override fun onFilterClick(view: View?) {
router.showFilterSheet() override fun onFilterClick(view: View?) {
} router.showFilterSheet()
}
override fun onEmptyActionClick() {
if (filterCoordinator.isFilterApplied) { override fun onEmptyActionClick() {
filterCoordinator.reset() if (filterCoordinator.isFilterApplied) {
} else { filterCoordinator.reset()
openInBrowser(null) // should never be called } else {
} openInBrowser(null) // should never be called
} }
}
override fun onFooterButtonClick() {
val filter = filterCoordinator.snapshot().listFilter override fun onFooterButtonClick() {
when { val filter = filterCoordinator.snapshot().listFilter
!filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE) when {
!filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR) !filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE)
filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG) !filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR)
} filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG)
} }
}
override fun onSecondaryErrorActionClick(error: Throwable) {
openInBrowser(error.getCauseUrl()) override fun onSecondaryErrorActionClick(error: Throwable) {
} openInBrowser(error.getCauseUrl())
}
private fun openInBrowser(url: String?) {
if (url?.isHttpUrl() == true) { override fun onClick(v: View?) = Unit // from Snackbar, do nothing
router.openBrowser(
url = url, private fun openInBrowser(url: String?) {
source = viewModel.source, if (url?.isHttpUrl() == true) {
title = viewModel.source.getTitle(requireContext()), router.openBrowser(
) url = url,
} else { source = viewModel.source,
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) title = viewModel.source.getTitle(requireContext()),
.show() )
} } else {
} Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
private inner class RemoteListMenuProvider : MenuProvider { }
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_list_remote, menu) private fun showSourceBrokenWarning() {
} val snackbar = Snackbar.make(
viewBinding?.recyclerView ?: return,
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.string.source_broken_warning,
R.id.action_source_settings -> { Snackbar.LENGTH_INDEFINITE,
router.openSourceSettings(viewModel.source) )
true snackbar.setAction(R.string.got_it, this)
} snackbar.show()
}
R.id.action_random -> {
viewModel.openRandom() private inner class RemoteListMenuProvider : MenuProvider {
true
} override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_list_remote, menu)
R.id.action_filter -> { }
onFilterClick(null)
true override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
} R.id.action_source_settings -> {
router.openSourceSettings(viewModel.source)
R.id.action_filter_reset -> { true
filterCoordinator.reset() }
true
} R.id.action_random -> {
viewModel.openRandom()
else -> false true
} }
override fun onPrepareMenu(menu: Menu) { R.id.action_filter -> {
super.onPrepareMenu(menu) onFilterClick(null)
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value true
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied }
}
} R.id.action_filter_reset -> {
filterCoordinator.reset()
companion object { true
}
const val ARG_SOURCE = "provider"
else -> false
fun newInstance(source: MangaSource) = RemoteListFragment().withArgs(1) { }
putString(ARG_SOURCE, source.name)
} override fun onPrepareMenu(menu: Menu) {
} super.onPrepareMenu(menu)
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied
}
}
companion object {
const val ARG_SOURCE = "provider"
fun newInstance(source: MangaSource) = RemoteListFragment().withArgs(1) {
putString(ARG_SOURCE, source.name)
}
}
} }

@ -44,6 +44,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.util.sizeOrZero import org.koitharu.kotatsu.parsers.util.sizeOrZero
import javax.inject.Inject import javax.inject.Inject
@ -65,6 +66,7 @@ open class RemoteListViewModel @Inject constructor(
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
val isRandomLoading = MutableStateFlow(false) val isRandomLoading = MutableStateFlow(false)
val onOpenManga = MutableEventFlow<Manga>() val onOpenManga = MutableEventFlow<Manga>()
val onSourceBroken = MutableEventFlow<Unit>()
protected val repository = mangaRepositoryFactory.create(source) protected val repository = mangaRepositoryFactory.create(source)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
@ -117,6 +119,11 @@ open class RemoteListViewModel @Inject constructor(
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
sourcesRepository.trackUsage(source) sourcesRepository.trackUsage(source)
} }
if (source is MangaParserSource && source.isBroken) {
// Just notify one. Will show reason in future
onSourceBroken.call(Unit)
}
} }
override fun onRefresh() { override fun onRefresh() {

@ -1,7 +1,10 @@
package org.koitharu.kotatsu.scrobbling.common.data package org.koitharu.kotatsu.scrobbling.common.data
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
@Dao @Dao
abstract class ScrobblingDao { abstract class ScrobblingDao {
@ -20,4 +23,20 @@ abstract class ScrobblingDao {
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long) abstract suspend fun delete(scrobbler: Int, mangaId: Long)
@Query("SELECT * FROM scrobblings ORDER BY scrobbler LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAll(offset: Int, limit: Int): List<ScrobblingEntity>
fun dumpEnabled(): Flow<ScrobblingEntity> = flow {
val window = 10
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAll(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it) }
}
}
} }

@ -169,4 +169,8 @@ class MangaSearchRepository @Inject constructor(
null, null,
)?.use { cursor -> cursor.count } ?: 0 )?.use { cursor -> cursor.count } ?: 0
} }
suspend fun getAuthors(source: MangaSource, limit: Int): List<String> {
return db.getMangaDao().findAuthorsBySource(source.name, limit)
}
} }

@ -11,11 +11,16 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.LocaleComparator
@ -24,8 +29,10 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.ActivityListPreference import org.koitharu.kotatsu.settings.utils.ActivityListPreference
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
@ -34,106 +41,145 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AppearanceSettingsFragment : class AppearanceSettingsFragment :
BasePreferenceFragment(R.string.appearance), BasePreferenceFragment(R.string.appearance),
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
@Inject @Inject
lateinit var activityRecreationHandle: ActivityRecreationHandle lateinit var activityRecreationHandle: ActivityRecreationHandle
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @Inject
addPreferencesFromResource(R.xml.pref_appearance) lateinit var appShortcutManager: AppShortcutManager
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
entryValues = ListMode.entries.names() addPreferencesFromResource(R.xml.pref_appearance)
setDefaultValueCompat(ListMode.GRID.name) findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
} findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run { entryValues = ListMode.entries.names()
entryValues = ProgressIndicatorMode.entries.names() setDefaultValueCompat(ListMode.GRID.name)
setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name) }
} findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run {
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run { entryValues = ProgressIndicatorMode.entries.names()
initLocalePicker(this) setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { }
activityIntent = Intent( findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
Settings.ACTION_APP_LOCALE_SETTINGS, initLocalePicker(this)
Uri.fromParts("package", context.packageName, null), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
) activityIntent = Intent(
} Settings.ACTION_APP_LOCALE_SETTINGS,
summaryProvider = Preference.SummaryProvider<ActivityListPreference> { Uri.fromParts("package", context.packageName, null),
val locale = AppCompatDelegate.getApplicationLocales().get(0) )
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system) }
} summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
setDefaultValueCompat("") val locale = AppCompatDelegate.getApplicationLocales().get(0)
} locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run { }
summaryProvider = MultiSummaryProvider(R.string.none) setDefaultValueCompat("")
} }
bindNavSummary() findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
} summaryProvider = MultiSummaryProvider(R.string.none)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
super.onViewCreated(view, savedInstanceState) appShortcutManager.isDynamicShortcutsAvailable()
settings.subscribe(this) findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
} ?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
override fun onDestroyView() { entryValues = ScreenshotsPolicy.entries.names()
settings.unsubscribe(this) setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
super.onDestroyView() }
} findPreference<MultiSelectListPreference>(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref ->
pref.entryValues = SearchSuggestionType.entries.names()
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray()
when (key) { pref.summaryProvider = MultiSummaryProvider(R.string.none)
AppSettings.KEY_THEME -> { pref.values = settings.searchSuggestionTypes.mapToSet { it.name }
AppCompatDelegate.setDefaultNightMode(settings.theme) }
} bindNavSummary()
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED, override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-> { super.onViewCreated(view, savedInstanceState)
postRestart() settings.subscribe(this)
} }
AppSettings.KEY_APP_LOCALE -> { override fun onDestroyView() {
AppCompatDelegate.setApplicationLocales(settings.appLocales) settings.unsubscribe(this)
} super.onDestroyView()
}
AppSettings.KEY_NAV_MAIN -> {
bindNavSummary() override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
} when (key) {
} AppSettings.KEY_THEME -> {
} AppCompatDelegate.setDefaultNightMode(settings.theme)
}
private fun postRestart() {
viewLifecycleOwner.lifecycle.postDelayed(400) { AppSettings.KEY_COLOR_THEME,
activityRecreationHandle.recreateAll() AppSettings.KEY_THEME_AMOLED,
} -> {
} postRestart()
}
private fun initLocalePicker(preference: ListPreference) {
val locales = preference.context.getLocalesConfig() AppSettings.KEY_APP_LOCALE -> {
.toList() AppCompatDelegate.setApplicationLocales(settings.appLocales)
.sortedWithSafe(LocaleComparator()) }
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) { AppSettings.KEY_NAV_MAIN -> {
getString(R.string.follow_system) bindNavSummary()
} else { }
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc) AppSettings.KEY_APP_PASSWORD -> {
} findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
} ?.isChecked = !settings.appPassword.isNullOrEmpty()
preference.entryValues = Array(locales.size + 1) { i -> }
if (i == 0) { }
"" }
} else {
locales[i - 1].toLanguageTag() override fun onPreferenceTreeClick(preference: Preference): Boolean {
} return when (preference.key) {
} AppSettings.KEY_PROTECT_APP -> {
} val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
private fun bindNavSummary() { pref.isChecked = false
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
pref.summary = settings.mainNavItems.joinToString { } else {
getString(it.title) settings.appPassword = null
} }
} true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun postRestart() {
viewLifecycleOwner.lifecycle.postDelayed(400) {
activityRecreationHandle.recreateAll()
}
}
private fun initLocalePicker(preference: ListPreference) {
val locales = preference.context.getLocalesConfig()
.toList()
.sortedWithSafe(LocaleComparator())
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.follow_system)
} else {
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc)
}
}
preference.entryValues = Array(locales.size + 1) { i ->
if (i == 0) {
""
} else {
locales[i - 1].toLanguageTag()
}
}
}
private fun bindNavSummary() {
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
pref.summary = settings.mainNavItems.joinToString {
getString(it.title)
}
}
} }

@ -1,66 +0,0 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.parsers.util.names
import java.net.Proxy
class NetworkSettingsFragment :
BasePreferenceFragment(R.string.network),
SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_network)
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
entryValues = DoHProvider.entries.names()
setDefaultValueCompat(DoHProvider.NONE.name)
}
bindProxySummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_SSL_BYPASS -> {
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
}
AppSettings.KEY_PROXY_TYPE,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PORT -> {
bindProxySummary()
}
}
}
private fun bindProxySummary() {
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
summary = when {
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
else -> "$address:$port"
}
}
}
}

@ -28,8 +28,8 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
addPreferencesFromResource(R.xml.pref_root_debug) addPreferencesFromResource(R.xml.pref_root_debug)
bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language) bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language)
bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages) bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages)
bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content) bindPreferenceSummary("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content)
bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion) bindPreferenceSummary("userdata", R.string.create_or_restore_backup, R.string.periodic_backups)
bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only) bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only)
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking)

@ -39,7 +39,7 @@ import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
@AndroidEntryPoint @AndroidEntryPoint
class SettingsActivity : class SettingsActivity :
@ -146,7 +146,7 @@ class SettingsActivity :
val fragment = when (intent?.action) { val fragment = when (intent?.action) {
AppRouter.ACTION_READER -> ReaderSettingsFragment() AppRouter.ACTION_READER -> ReaderSettingsFragment()
AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
AppRouter.ACTION_HISTORY -> UserDataSettingsFragment() AppRouter.ACTION_HISTORY -> BackupsSettingsFragment()
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment() AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() AppRouter.ACTION_SOURCES -> SourcesSettingsFragment()

@ -0,0 +1,77 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference
import java.net.Proxy
class StorageAndNetworkSettingsFragment :
BasePreferenceFragment(R.string.storage_and_network),
SharedPreferences.OnSharedPreferenceChangeListener {
private val viewModel by viewModels<StorageAndNetworkSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_network_storage)
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
entryValues = DoHProvider.entries.names()
setDefaultValueCompat(DoHProvider.NONE.name)
}
bindProxySummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
settings.subscribe(this)
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_SSL_BYPASS -> {
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
}
AppSettings.KEY_PROXY_TYPE,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PORT -> {
bindProxySummary()
}
}
}
private fun bindProxySummary() {
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
summary = when {
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
else -> "$address:$port"
}
}
}
}

@ -0,0 +1,52 @@
package org.koitharu.kotatsu.settings
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsage
import javax.inject.Inject
@HiltViewModel
class StorageAndNetworkSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
) : BaseViewModel() {
val storageUsage: StateFlow<StorageUsage?> = flow {
emit(loadStorageUsage())
}.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(1000), null)
private suspend fun loadStorageUsage(): StorageUsage {
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
val storageSize = storageManager.computeStorageSize()
val availableSpace = storageManager.computeAvailableSize()
val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace
return StorageUsage(
savedManga = StorageUsage.Item(
bytes = storageSize,
percent = (storageSize.toDouble() / totalBytes).toFloat(),
),
pagesCache = StorageUsage.Item(
bytes = pagesCacheSize,
percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(),
),
otherCache = StorageUsage.Item(
bytes = otherCacheSize,
percent = (otherCacheSize.toDouble() / totalBytes).toFloat(),
),
available = StorageUsage.Item(
bytes = availableSpace,
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
),
)
}
}

@ -137,7 +137,7 @@ class AppUpdateActivity : BaseActivity<ActivityAppUpdateBinding>(), View.OnClick
viewModel.installIntent.value?.let { intent -> viewModel.installIntent.value?.let { intent ->
try { try {
startActivity(intent) startActivity(intent)
} catch (e: ActivityNotFoundException) { } catch (e: Exception) {
onError(e) onError(e)
} }
return return

@ -11,6 +11,6 @@ data class SettingsItem(
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is SettingsItem && other.key == key return other is SettingsItem && other.key == key && other.fragmentClass == fragmentClass
} }
} }

@ -13,106 +13,118 @@ import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragme
import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.settings.AppearanceSettingsFragment import org.koitharu.kotatsu.settings.AppearanceSettingsFragment
import org.koitharu.kotatsu.settings.DownloadsSettingsFragment import org.koitharu.kotatsu.settings.DownloadsSettingsFragment
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
import org.koitharu.kotatsu.settings.ProxySettingsFragment import org.koitharu.kotatsu.settings.ProxySettingsFragment
import org.koitharu.kotatsu.settings.ReaderSettingsFragment import org.koitharu.kotatsu.settings.ReaderSettingsFragment
import org.koitharu.kotatsu.settings.ServicesSettingsFragment import org.koitharu.kotatsu.settings.ServicesSettingsFragment
import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment
import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class SettingsSearchHelper @Inject constructor( class SettingsSearchHelper @Inject constructor(
@LocalizedAppContext private val context: Context, @LocalizedAppContext private val context: Context,
) { ) {
fun inflatePreferences(): List<SettingsItem> { fun inflatePreferences(): List<SettingsItem> {
val preferenceManager = PreferenceManager(context) val preferenceManager = PreferenceManager(context)
val result = ArrayList<SettingsItem>() val result = ArrayList<SettingsItem>()
preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java)
preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java)
preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java)
preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java) preferenceManager.inflateTo(
preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java) result,
preferenceManager.inflateTo( R.xml.pref_network_storage,
result, emptyList(),
R.xml.pref_storage, StorageAndNetworkSettingsFragment::class.java,
listOf(context.getString(R.string.data_and_privacy)), )
StorageManageSettingsFragment::class.java, preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java)
) preferenceManager.inflateTo(
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) result,
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) R.xml.pref_data_cleanup,
preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) listOf(context.getString(R.string.storage_and_network)),
preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java) DataCleanupSettingsFragment::class.java,
preferenceManager.inflateTo( )
result, preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
R.xml.pref_backup_periodic, preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
listOf(context.getString(R.string.data_and_privacy)), preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java)
PeriodicalBackupSettingsFragment::class.java, preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java)
) preferenceManager.inflateTo(
preferenceManager.inflateTo( result,
result, R.xml.pref_backup_periodic,
R.xml.pref_proxy, listOf(context.getString(R.string.backup_restore)),
listOf(context.getString(R.string.proxy)), PeriodicalBackupSettingsFragment::class.java,
ProxySettingsFragment::class.java, )
) preferenceManager.inflateTo(
preferenceManager.inflateTo( result,
result, R.xml.pref_proxy,
R.xml.pref_suggestions, listOf(context.getString(R.string.storage_and_network)),
listOf(context.getString(R.string.suggestions)), ProxySettingsFragment::class.java,
SuggestionsSettingsFragment::class.java, )
) preferenceManager.inflateTo(
preferenceManager.inflateTo( result,
result, R.xml.pref_suggestions,
R.xml.pref_sources, listOf(context.getString(R.string.services)),
listOf(context.getString(R.string.remote_sources)), SuggestionsSettingsFragment::class.java,
SourcesSettingsFragment::class.java, )
) preferenceManager.inflateTo(
return result result,
} R.xml.pref_discord,
listOf(context.getString(R.string.services)),
DiscordSettingsFragment::class.java,
)
preferenceManager.inflateTo(
result,
R.xml.pref_sources,
listOf(),
SourcesSettingsFragment::class.java,
)
return result
}
private fun PreferenceManager.inflateTo( private fun PreferenceManager.inflateTo(
result: MutableList<SettingsItem>, result: MutableList<SettingsItem>,
@XmlRes resId: Int, @XmlRes resId: Int,
breadcrumbs: List<String>, breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat> fragmentClass: Class<out PreferenceFragmentCompat>
) { ) {
val screen = inflateFromResource(context, resId, null) val screen = inflateFromResource(context, resId, null)
val screenTitle = screen.title?.toString() val screenTitle = screen.title?.toString()
screen.inflateTo( screen.inflateTo(
result = result, result = result,
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
fragmentClass = fragmentClass, fragmentClass = fragmentClass,
) )
} }
private fun PreferenceScreen.inflateTo( private fun PreferenceScreen.inflateTo(
result: MutableList<SettingsItem>, result: MutableList<SettingsItem>,
breadcrumbs: List<String>, breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat> fragmentClass: Class<out PreferenceFragmentCompat>
): Unit = repeat(preferenceCount) { i -> ): Unit = repeat(preferenceCount) { i ->
val pref = this[i] val pref = this[i]
if (pref is PreferenceScreen) { if (pref is PreferenceScreen) {
val screenTitle = pref.title?.toString() val screenTitle = pref.title?.toString()
pref.inflateTo( pref.inflateTo(
result = result, result = result,
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
fragmentClass = fragmentClass, fragmentClass = fragmentClass,
) )
} else { } else {
result.add( result.add(
SettingsItem( SettingsItem(
key = pref.key ?: return@repeat, key = pref.key ?: return@repeat,
title = pref.title ?: return@repeat, title = pref.title ?: return@repeat,
breadcrumbs = breadcrumbs, breadcrumbs = breadcrumbs,
fragmentClass = fragmentClass, fragmentClass = fragmentClass,
), ),
) )
} }
} }
} }

@ -11,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@ -31,6 +32,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources),
entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray() entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray()
setDefaultValueCompat(SourcesSortOrder.MANUAL.name) setDefaultValueCompat(SourcesSortOrder.MANUAL.name)
} }
findPreference<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
entryValues = TriStateOption.entries.names()
setDefaultValueCompat(TriStateOption.ASK.name)
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -0,0 +1,99 @@
package org.koitharu.kotatsu.settings.userdata
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.domain.BackupUtils
import org.koitharu.kotatsu.backups.ui.backup.BackupService
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
@AndroidEntryPoint
class BackupsSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
ActivityResultCallback<Uri?> {
private val viewModel: BackupsSettingsViewModel by viewModels()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this,
)
private val backupCreateCall = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
if (uri != null) {
if (!BackupService.start(requireContext(), uri)) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backups)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindPeriodicalBackupSummary()
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_BACKUP -> {
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
true
}
AppSettings.KEY_RESTORE -> {
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
router.showBackupRestoreDialog(result)
}
}
private fun bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
preference.summary = if (freq == 0L) {
getString(R.string.disabled)
} else {
val index = entryValues.indexOf(freq.toString())
entries.getOrNull(index)
}
}
}
}

@ -0,0 +1,29 @@
package org.koitharu.kotatsu.settings.userdata
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import javax.inject.Inject
@HiltViewModel
class BackupsSettingsViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
valueProducer = { isPeriodicalBackupEnabled },
).flatMapLatest { isEnabled ->
if (isEnabled) {
settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
valueProducer = { periodicalBackupFrequency },
)
} else {
flowOf(0)
}
}
}

@ -1,172 +0,0 @@
package org.koitharu.kotatsu.settings.userdata
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.domain.BackupUtils
import org.koitharu.kotatsu.backups.ui.backup.BackupService
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import javax.inject.Inject
@AndroidEntryPoint
class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy),
SharedPreferences.OnSharedPreferenceChangeListener,
ActivityResultCallback<Uri?> {
@Inject
lateinit var appShortcutManager: AppShortcutManager
private val viewModel: UserDataSettingsViewModel by viewModels()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this,
)
private val backupCreateCall = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
if (uri != null) {
if (!BackupService.start(requireContext(), uri)) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_user_data)
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
appShortcutManager.isDynamicShortcutsAvailable()
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
entryValues = ScreenshotsPolicy.entries.names()
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
}
findPreference<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
entryValues = TriStateOption.entries.names()
setDefaultValueCompat(TriStateOption.ASK.name)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindPeriodicalBackupSummary()
findPreference<MultiSelectListPreference>(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref ->
pref.entryValues = SearchSuggestionType.entries.names()
pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray()
pref.summaryProvider = MultiSummaryProvider(R.string.none)
pref.values = settings.searchSuggestionTypes.mapToSet { it.name }
}
findPreference<Preference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner) { size ->
pref.summary = if (size < 0L) {
pref.context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(pref.context, size)
}
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_BACKUP -> {
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
true
}
AppSettings.KEY_RESTORE -> {
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
true
}
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
router.showBackupRestoreDialog(result)
}
}
private fun bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
preference.summary = if (freq == 0L) {
getString(R.string.disabled)
} else {
val index = entryValues.indexOf(freq.toString())
entries.getOrNull(index)
}
}
}
}

@ -1,54 +0,0 @@
package org.koitharu.kotatsu.settings.userdata
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.local.data.LocalStorageManager
import javax.inject.Inject
@HiltViewModel
class UserDataSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val settings: AppSettings,
) : BaseViewModel() {
val storageUsage = MutableStateFlow(-1L)
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
valueProducer = { isPeriodicalBackupEnabled },
).flatMapLatest { isEnabled ->
if (isEnabled) {
settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
valueProducer = { periodicalBackupFrequency },
)
} else {
flowOf(0)
}
}
private var storageUsageJob: Job? = null
init {
loadStorageUsage()
}
private fun loadStorageUsage(): Job {
val prevJob = storageUsageJob
return launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize()
storageUsage.value = totalBytes
}.also {
storageUsageJob = it
}
}
}

@ -0,0 +1,173 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.local.data.CacheDir
@AndroidEntryPoint
class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) {
private val viewModel by viewModels<DataCleanupSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_data_cleanup)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies()
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory()
true
}
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
true
}
AppSettings.KEY_WEBVIEW_CLEAR -> {
viewModel.clearBrowserData()
true
}
AppSettings.KEY_CLEAR_MANGA_DATA -> {
viewModel.clearMangaData()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewModel.clearUpdatesFeed()
true
}
else -> super.onPreferenceTreeClick(preference)
}
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
val c = context ?: return
val text = if (result.first == 0 && result.second == 0L) {
c.getString(R.string.no_chapters_deleted)
} else {
c.getString(
R.string.chapters_deleted_pattern,
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
FileSize.BYTES.format(c, result.second),
)
}
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
}
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
stateFlow.observe(viewLifecycleOwner) { size ->
summary = if (size < 0) {
context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(context, size)
}
}
}
private fun clearSearchHistory() {
buildAlertDialog(context ?: return) {
setTitle(R.string.clear_search_history)
setMessage(R.string.text_clear_search_history_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearSearchHistory()
}
}.show()
}
private fun clearCookies() {
buildAlertDialog(context ?: return) {
setTitle(R.string.clear_cookies)
setMessage(R.string.text_clear_cookies_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearCookies()
}
}.show()
}
private fun cleanupChapters() {
buildAlertDialog(context ?: return) {
setTitle(R.string.delete_read_chapters)
setMessage(R.string.delete_read_chapters_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.delete) { _, _ ->
viewModel.cleanupChapters()
}
}.show()
}
}

@ -0,0 +1,184 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.annotation.SuppressLint
import android.webkit.WebStorage
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import coil3.ImageLoader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@HiltViewModel
class DataCleanupSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
val isBrowserDataCleanupEnabled: Boolean
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
init {
CacheDir.entries.forEach {
cacheSizes[it] = MutableStateFlow(-1L)
}
launchJob(Dispatchers.Default) {
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
}
launchJob(Dispatchers.Default) {
feedItemsCount.value = trackingRepository.getLogsCount()
}
CacheDir.entries.forEach { cache ->
launchJob(Dispatchers.Default) {
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
}
}
launchJob(Dispatchers.Default) {
httpCacheSize.value = runInterruptible { httpCache.size() }
}
}
fun clearCache(key: String, vararg caches: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
for (cache in caches) {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
if (cache == CacheDir.THUMBS) {
coil.memoryCache?.clear()
}
}
} finally {
loadingKeys.update { it - key }
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
} finally {
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
}
}
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
searchRepository.clearSearchHistory()
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
}
}
fun clearCookies() {
launchJob {
cookieJar.clear()
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
}
}
@SuppressLint("RequiresFeature")
fun clearBrowserData() {
launchJob {
try {
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
val storage = WebStorage.getInstance()
suspendCoroutine { cont ->
WebStorageCompat.deleteBrowsingData(storage) {
cont.resume(Unit)
}
}
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
}
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
}
}
}
fun clearMangaData() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
trackingRepository.gc()
val repository = mangaDataRepositoryProvider.get()
repository.cleanupLocalManga()
repository.cleanupDatabase()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
}
}
}
fun cleanupChapters() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
val oldSize = storageManager.computeStorageSize()
val chaptersCount = deleteReadChaptersUseCase.invoke()
val newSize = storageManager.computeStorageSize()
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
} finally {
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
}
}
}
}

@ -1,173 +0,0 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.local.data.CacheDir
@AndroidEntryPoint
class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) {
private val viewModel by viewModels<StorageManageSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_storage)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies()
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory()
true
}
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
true
}
AppSettings.KEY_WEBVIEW_CLEAR -> {
viewModel.clearBrowserData()
true
}
AppSettings.KEY_CLEAR_MANGA_DATA -> {
viewModel.clearMangaData()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewModel.clearUpdatesFeed()
true
}
else -> super.onPreferenceTreeClick(preference)
}
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
val c = context ?: return
val text = if (result.first == 0 && result.second == 0L) {
c.getString(R.string.no_chapters_deleted)
} else {
c.getString(
R.string.chapters_deleted_pattern,
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
FileSize.BYTES.format(c, result.second),
)
}
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
}
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
stateFlow.observe(viewLifecycleOwner) { size ->
summary = if (size < 0) {
context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(context, size)
}
}
}
private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearSearchHistory()
}.show()
}
private fun clearCookies() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_cookies)
.setMessage(R.string.text_clear_cookies_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearCookies()
}.show()
}
private fun cleanupChapters() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_read_chapters)
.setMessage(R.string.delete_read_chapters_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.cleanupChapters()
}.show()
}
}

@ -1,226 +0,0 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.annotation.SuppressLint
import android.webkit.WebStorage
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import coil3.ImageLoader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@HiltViewModel
class StorageManageSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
val isBrowserDataCleanupEnabled: Boolean
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
private var storageUsageJob: Job? = null
init {
CacheDir.entries.forEach {
cacheSizes[it] = MutableStateFlow(-1L)
}
launchJob(Dispatchers.Default) {
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
}
launchJob(Dispatchers.Default) {
feedItemsCount.value = trackingRepository.getLogsCount()
}
CacheDir.entries.forEach { cache ->
launchJob(Dispatchers.Default) {
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
}
}
launchJob(Dispatchers.Default) {
httpCacheSize.value = runInterruptible { httpCache.size() }
}
loadStorageUsage()
}
fun clearCache(key: String, vararg caches: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
for (cache in caches) {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
if (cache == CacheDir.THUMBS) {
coil.memoryCache?.clear()
}
}
loadStorageUsage()
} finally {
loadingKeys.update { it - key }
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
loadStorageUsage()
} finally {
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
}
}
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
searchRepository.clearSearchHistory()
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
}
}
fun clearCookies() {
launchJob {
cookieJar.clear()
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
}
}
@SuppressLint("RequiresFeature")
fun clearBrowserData() {
launchJob {
try {
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
val storage = WebStorage.getInstance()
suspendCoroutine { cont ->
WebStorageCompat.deleteBrowsingData(storage) {
cont.resume(Unit)
}
}
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
}
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
}
}
}
fun clearMangaData() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
trackingRepository.gc()
val repository = mangaDataRepositoryProvider.get()
repository.cleanupLocalManga()
repository.cleanupDatabase()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
}
}
}
fun cleanupChapters() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
val oldSize = storageUsage.firstNotNull().savedManga.bytes
val chaptersCount = deleteReadChaptersUseCase.invoke()
loadStorageUsage().join()
val newSize = storageUsage.firstNotNull().savedManga.bytes
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
} finally {
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
}
}
}
private fun loadStorageUsage(): Job {
val prevJob = storageUsageJob
return launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
val storageSize = storageManager.computeStorageSize()
val availableSpace = storageManager.computeAvailableSize()
val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace
storageUsage.value = StorageUsage(
savedManga = StorageUsage.Item(
bytes = storageSize,
percent = (storageSize.toDouble() / totalBytes).toFloat(),
),
pagesCache = StorageUsage.Item(
bytes = pagesCacheSize,
percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(),
),
otherCache = StorageUsage.Item(
bytes = otherCacheSize,
percent = (otherCacheSize.toDouble() / totalBytes).toFloat(),
),
available = StorageUsage.Item(
bytes = availableSpace,
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
),
)
}.also {
storageUsageJob = it
}
}
}

@ -7,8 +7,12 @@ import androidx.room.RawQuery
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import kotlin.collections.forEach
@Dao @Dao
abstract class StatsDao { abstract class StatsDao {
@ -61,4 +65,19 @@ abstract class StatsDao {
protected abstract suspend fun getDurationStatsImpl( protected abstract suspend fun getDurationStatsImpl(
query: SupportSQLiteQuery query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long> ): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
@Query("SELECT * FROM stats ORDER BY started_at LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAll(offset: Int, limit: Int): List<StatsEntity>
fun dumpEnabled(): Flow<StatsEntity> = flow {
val window = 10
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAll(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it) }
}
}
} }

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M13,2.05V5.08C16.39,5.57 19,8.47 19,12C19,12.9 18.82,13.75 18.5,14.54L21.12,16.07C21.68,14.83 22,13.45 22,12C22,6.82 18.05,2.55 13,2.05M12,19A7,7 0 0,1 5,12C5,8.47 7.61,5.57 11,5.08V2.05C5.94,2.55 2,6.81 2,12A10,10 0 0,0 12,22C15.3,22 18.23,20.39 20.05,17.91L17.45,16.38C16.17,18 14.21,19 12,19Z" />
</vector>

@ -42,7 +42,7 @@
android:importantForAutofill="no" android:importantForAutofill="no"
android:minHeight="48dp" /> android:minHeight="48dp" />
<ImageView <ImageButton
android:id="@+id/dropdown" android:id="@+id/dropdown"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="48dp"

@ -1,82 +1,88 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar <org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar" android:id="@+id/headerBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:title="@string/chapters" /> app:title="@string/chapters" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:baselineAligned="false" android:baselineAligned="false"
android:orientation="horizontal"> android:orientation="horizontal">
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/tabs" android:id="@+id/tabs"
style="?tabSecondaryStyle" style="?tabSecondaryStyle"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical" android:layout_gravity="start|center_vertical"
android:layout_weight="1" android:layout_weight="1"
android:background="@null" android:background="@null"
app:tabGravity="start" app:tabGravity="start"
app:tabIndicator="@drawable/bg_tab_pill" app:tabIndicator="@drawable/bg_tab_pill"
app:tabIndicatorAnimationMode="fade" app:tabIndicatorAnimationMode="fade"
app:tabIndicatorColor="?colorSurfaceDim" app:tabIndicatorColor="?colorSurfaceDim"
app:tabIndicatorFullWidth="true" app:tabIndicatorFullWidth="true"
app:tabIndicatorGravity="stretch" app:tabIndicatorGravity="stretch"
app:tabInlineLabel="true" app:tabInlineLabel="true"
app:tabMinWidth="0dp" app:tabMinWidth="0dp"
app:tabMode="scrollable" app:tabMode="scrollable"
app:tabUnboundedRipple="true" /> app:tabUnboundedRipple="true" />
<com.google.android.material.button.MaterialSplitButton <com.google.android.material.button.MaterialSplitButton
android:id="@+id/split_button_read" android:id="@+id/split_button_read"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical" android:layout_gravity="end|center_vertical"
android:paddingTop="0dp" android:paddingTop="0dp"
android:paddingBottom="0dp"> android:paddingBottom="0dp">
<Button <Button
android:id="@+id/button_read" android:id="@+id/button_read"
style="?materialSplitButtonLeadingFilledStyle" style="?materialSplitButtonLeadingFilledStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="@dimen/read_button_min_width" android:minWidth="@dimen/read_button_min_width"
android:text="@string/read" /> android:text="@string/read" />
<Button <Button
android:id="@+id/button_read_menu" android:id="@+id/button_read_menu"
style="?materialSplitButtonIconFilledStyle" style="?materialSplitButtonIconFilledStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="@string/show_menu" android:contentDescription="@string/show_menu"
app:icon="?expandCollapseIndicator" app:icon="?expandCollapseIndicator"
app:toggleCheckedStateOnClick="false" /> app:toggleCheckedStateOnClick="false" />
</com.google.android.material.button.MaterialSplitButton> </com.google.android.material.button.MaterialSplitButton>
</LinearLayout> </LinearLayout>
</com.google.android.material.appbar.MaterialToolbar> </com.google.android.material.appbar.MaterialToolbar>
<androidx.viewpager2.widget.ViewPager2 <org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout
android:id="@+id/pager" android:id="@+id/layout_touchBlock"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout>
</LinearLayout> </LinearLayout>

@ -1,257 +1,329 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar <org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar" android:id="@+id/headerBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:title="@string/filter" /> app:title="@string/filter" />
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/scrollView" android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="0dp"
android:clipToPadding="false" android:layout_weight="1"
android:scrollIndicators="top" android:clipToPadding="false"
tools:ignore="UnusedAttribute"> android:scrollIndicators="top|bottom"
tools:ignore="UnusedAttribute">
<LinearLayout
android:id="@+id/layout_body" <LinearLayout
android:layout_width="match_parent" android:id="@+id/layout_body"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:orientation="vertical" android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/margin_small" android:orientation="vertical"
android:paddingBottom="@dimen/margin_normal"> android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_order" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_width="match_parent" android:id="@+id/layout_order"
android:layout_height="wrap_content" android:layout_width="match_parent"
app:title="@string/sort_order"> android:layout_height="wrap_content"
app:title="@string/sort_order">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_order" <com.google.android.material.card.MaterialCardView
style="?materialCardViewOutlinedStyle" android:id="@+id/card_order"
android:layout_width="match_parent" style="?materialCardViewOutlinedStyle"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"> android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<Spinner
android:id="@+id/spinner_order" <Spinner
android:layout_width="match_parent" android:id="@+id/spinner_order"
android:layout_height="@dimen/spinner_height" android:layout_width="match_parent"
android:minHeight="?listPreferredItemHeightSmall" android:layout_height="@dimen/spinner_height"
android:paddingHorizontal="8dp" /> android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" />
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_locale" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_width="match_parent" android:id="@+id/layout_saved_filters"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:layout_marginTop="@dimen/margin_small" android:layout_height="wrap_content"
app:title="@string/language"> android:layout_marginTop="@dimen/margin_small"
app:title="@string/saved_filters">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_locale" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
style="?materialCardViewOutlinedStyle" android:id="@+id/chips_saved_filters"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"> android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<Spinner
android:id="@+id/spinner_locale" </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:minHeight="?listPreferredItemHeightSmall" android:id="@+id/layout_locale"
android:paddingHorizontal="8dp" android:layout_width="match_parent"
android:popupBackground="@drawable/m3_spinner_popup_background" /> android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
</com.google.android.material.card.MaterialCardView> app:title="@string/language">
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> <com.google.android.material.card.MaterialCardView
android:id="@+id/card_locale"
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout style="?materialCardViewOutlinedStyle"
android:id="@+id/layout_original_locale" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small">
app:title="@string/original_language">
<Spinner
<com.google.android.material.card.MaterialCardView android:id="@+id/spinner_locale"
android:id="@+id/card_original_locale" android:layout_width="match_parent"
style="?materialCardViewOutlinedStyle" android:layout_height="@dimen/spinner_height"
android:layout_width="match_parent" android:minHeight="?listPreferredItemHeightSmall"
android:layout_height="wrap_content" android:paddingHorizontal="8dp"
android:layout_marginHorizontal="@dimen/margin_small" android:popupBackground="@drawable/m3_spinner_popup_background" />
android:layout_marginTop="@dimen/margin_small">
</com.google.android.material.card.MaterialCardView>
<Spinner
android:id="@+id/spinner_original_locale" </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:minHeight="?listPreferredItemHeightSmall" android:id="@+id/layout_original_locale"
android:paddingHorizontal="8dp" android:layout_width="match_parent"
android:popupBackground="@drawable/m3_spinner_popup_background" /> android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
</com.google.android.material.card.MaterialCardView> app:title="@string/original_language">
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> <com.google.android.material.card.MaterialCardView
android:id="@+id/card_original_locale"
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout style="?materialCardViewOutlinedStyle"
android:id="@+id/layout_genres" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small">
app:showMoreButton="true"
app:title="@string/genres"> <Spinner
android:id="@+id/spinner_original_locale"
<org.koitharu.kotatsu.core.ui.widgets.ChipsView android:layout_width="match_parent"
android:id="@+id/chips_genres" android:layout_height="@dimen/spinner_height"
android:layout_width="match_parent" android:minHeight="?listPreferredItemHeightSmall"
android:layout_height="wrap_content" android:paddingHorizontal="8dp"
android:layout_marginHorizontal="@dimen/margin_small" android:popupBackground="@drawable/m3_spinner_popup_background" />
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> </com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_genresExclude" android:id="@+id/layout_genres"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="true" app:showMoreButton="true"
app:title="@string/genres_exclude"> app:title="@string/genres">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genresExclude" android:id="@+id/chips_genres"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_types" android:id="@+id/layout_genresExclude"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
app:title="@string/type"> app:showMoreButton="true"
app:title="@string/genres_exclude">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_types" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:layout_width="match_parent" android:id="@+id/chips_genresExclude"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:layout_marginHorizontal="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_state" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_width="match_parent" android:id="@+id/layout_author"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:layout_marginTop="@dimen/margin_small" android:layout_height="wrap_content"
app:title="@string/state"> android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="false"
<org.koitharu.kotatsu.core.ui.widgets.ChipsView app:title="@string/author">
android:id="@+id/chips_state"
android:layout_width="match_parent" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:layout_height="wrap_content" android:id="@+id/chips_author"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_width="match_parent"
android:layout_marginTop="@dimen/margin_small" android:layout_height="wrap_content"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
android:id="@+id/layout_contentRating"
android:layout_width="match_parent" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_height="wrap_content" android:id="@+id/layout_types"
android:layout_marginTop="@dimen/margin_small" android:layout_width="match_parent"
app:title="@string/content_rating"> android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
<org.koitharu.kotatsu.core.ui.widgets.ChipsView app:title="@string/type">
android:id="@+id/chips_contentRating"
android:layout_width="match_parent" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:layout_height="wrap_content" android:id="@+id/chips_types"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_width="match_parent"
android:layout_marginTop="@dimen/margin_small" android:layout_height="wrap_content"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
android:id="@+id/layout_demographics"
android:layout_width="match_parent" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_height="wrap_content" android:id="@+id/layout_state"
android:layout_marginTop="@dimen/margin_small" android:layout_width="match_parent"
app:title="@string/demographics"> android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
<org.koitharu.kotatsu.core.ui.widgets.ChipsView app:title="@string/state">
android:id="@+id/chips_demographics"
android:layout_width="match_parent" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:layout_height="wrap_content" android:id="@+id/chips_state"
android:layout_marginHorizontal="@dimen/margin_small" android:layout_width="match_parent"
android:layout_marginTop="@dimen/margin_small" android:layout_height="wrap_content"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
android:id="@+id/layout_year"
android:layout_width="match_parent" <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:layout_height="wrap_content" android:id="@+id/layout_contentRating"
android:layout_marginTop="@dimen/margin_small" android:layout_width="match_parent"
app:title="@string/year"> android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
<com.google.android.material.slider.Slider app:title="@string/content_rating">
android:id="@+id/slider_year"
android:layout_width="match_parent" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:layout_height="wrap_content" android:id="@+id/chips_contentRating"
android:stepSize="1" android:layout_width="match_parent"
app:labelBehavior="gone" android:layout_height="wrap_content"
app:tickVisible="true" android:layout_marginHorizontal="@dimen/margin_small"
tools:value="2020" android:layout_marginTop="@dimen/margin_small"
tools:valueFrom="1900" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout android:id="@+id/layout_demographics"
android:id="@+id/layout_yearsRange" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small" app:title="@string/demographics">
app:title="@string/years">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
<com.google.android.material.slider.RangeSlider android:id="@+id/chips_demographics"
android:id="@+id/slider_yearsRange" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_normal" android:layout_marginTop="@dimen/margin_small"
android:stepSize="1" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
app:labelBehavior="gone"
app:tickVisible="true" </org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
tools:valueFrom="1900"
tools:valueTo="2090" /> <org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_year"
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout> android:layout_width="match_parent"
android:layout_height="wrap_content"
</LinearLayout> android:layout_marginTop="@dimen/margin_small"
</androidx.core.widget.NestedScrollView> app:title="@string/year">
<com.google.android.material.slider.Slider
android:id="@+id/slider_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:value="2020"
tools:valueFrom="1900"
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/years">
<com.google.android.material.slider.RangeSlider
android:id="@+id/slider_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:valueFrom="1900"
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.dockedtoolbar.DockedToolbarLayout
android:id="@+id/docked_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="false">
<LinearLayout
android:id="@+id/layout_bottom"
android:layout_width="match_parent"
android:layout_height="@dimen/m3_comp_toolbar_docked_container_height"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_save"
style="?materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/save"
tools:enabled="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_done"
style="?materialButtonTonalStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_weight="1"
android:text="@string/done" />
</LinearLayout>
</com.google.android.material.dockedtoolbar.DockedToolbarLayout>
</LinearLayout> </LinearLayout>

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/screen_padding"
android:paddingTop="@dimen/margin_small">
<AutoCompleteTextView
android:id="@+id/autoCompleteTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/dropdown"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_gravity="center_vertical|end"
android:background="?selectableItemBackgroundBorderless"
android:paddingBottom="2dp"
android:scaleType="center"
android:src="@drawable/ic_expand_more"
tools:ignore="ContentDescription" />
</RelativeLayout>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_rename"
android:title="@string/rename" />
<item
android:id="@+id/action_delete"
android:title="@string/delete" />
</menu>

@ -667,4 +667,8 @@
<string name="error_not_image">無効な形式です: 画像が期待されましたが %s が入力されました</string> <string name="error_not_image">無効な形式です: 画像が期待されましたが %s が入力されました</string>
<string name="start_download">ダウンロード開始</string> <string name="start_download">ダウンロード開始</string>
<string name="save_manga_confirm">選択したマンガを保存しますか?通信量とディスク容量を消費する可能性があります</string> <string name="save_manga_confirm">選択したマンガを保存しますか?通信量とディスク容量を消費する可能性があります</string>
<string name="save_manga">マンガを保存</string>
<string name="genre">ジャンル</string>
<string name="download_added">ダウンロードに追加</string>
<string name="chapter_selection_hint">チャプターリストの項目を長押しすると、ダウンロードするチャプターを選択できます。</string>
</resources> </resources>

@ -866,4 +866,5 @@
<string name="pull_bottom_no_next">Sonraki bölüm yok</string> <string name="pull_bottom_no_next">Sonraki bölüm yok</string>
<string name="enable_pull_gesture_title">Çekme hareketini etkinleştir</string> <string name="enable_pull_gesture_title">Çekme hareketini etkinleştir</string>
<string name="enable_pull_gesture_summary">Webtoon modunda bölüm değiştirmek için çekme hareketi kullan</string> <string name="enable_pull_gesture_summary">Webtoon modunda bölüm değiştirmek için çekme hareketi kullan</string>
<string name="two_page_scroll_sensitivity">İki Sayfa Kaydırma Hassaslığı</string>
</resources> </resources>

@ -866,4 +866,5 @@
<string name="pull_bottom_no_next">Немає наступного розділу</string> <string name="pull_bottom_no_next">Немає наступного розділу</string>
<string name="enable_pull_gesture_title">Увімкнути жест потягування</string> <string name="enable_pull_gesture_title">Увімкнути жест потягування</string>
<string name="enable_pull_gesture_summary">Використовуйте жест потягування для перемикання розділів у вебтуні</string> <string name="enable_pull_gesture_summary">Використовуйте жест потягування для перемикання розділів у вебтуні</string>
<string name="two_page_scroll_sensitivity">Чутливість прокручування на дві сторінки</string>
</resources> </resources>

File diff suppressed because it is too large Load Diff

@ -1,122 +1,149 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen <PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/appearance"> android:title="@string/appearance">
<org.koitharu.kotatsu.settings.utils.ThemeChooserPreference <org.koitharu.kotatsu.settings.utils.ThemeChooserPreference
android:key="color_theme" android:key="color_theme"
android:title="@string/color_theme" /> android:title="@string/color_theme" />
<ListPreference <ListPreference
android:defaultValue="-1" android:defaultValue="-1"
android:entries="@array/themes" android:entries="@array/themes"
android:entryValues="@array/values_theme" android:entryValues="@array/values_theme"
android:key="theme" android:key="theme"
android:title="@string/theme" android:title="@string/theme"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:key="amoled_theme" android:key="amoled_theme"
android:summary="@string/black_dark_theme_summary" android:summary="@string/black_dark_theme_summary"
android:title="@string/black_dark_theme" /> android:title="@string/black_dark_theme" />
<org.koitharu.kotatsu.settings.utils.ActivityListPreference <org.koitharu.kotatsu.settings.utils.ActivityListPreference
android:key="app_locale" android:key="app_locale"
android:title="@string/language" /> android:title="@string/language" />
<PreferenceCategory android:title="@string/manga_list"> <PreferenceCategory android:title="@string/manga_list">
<ListPreference <ListPreference
android:entries="@array/list_modes" android:entries="@array/list_modes"
android:key="list_mode_2" android:key="list_mode_2"
android:title="@string/list_mode" android:title="@string/list_mode"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<org.koitharu.kotatsu.settings.utils.SliderPreference <org.koitharu.kotatsu.settings.utils.SliderPreference
android:key="grid_size" android:key="grid_size"
android:stepSize="5" android:stepSize="5"
android:title="@string/grid_size" android:title="@string/grid_size"
android:valueFrom="50" android:valueFrom="50"
android:valueTo="150" android:valueTo="150"
app:defaultValue="100" /> app:defaultValue="100" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="quick_filter" android:key="quick_filter"
android:summary="@string/show_quick_filters_summary" android:summary="@string/show_quick_filters_summary"
android:title="@string/show_quick_filters" /> android:title="@string/show_quick_filters" />
<ListPreference <ListPreference
android:entries="@array/progress_indicators" android:entries="@array/progress_indicators"
android:key="progress_indicators" android:key="progress_indicators"
android:title="@string/show_reading_indicators" android:title="@string/show_reading_indicators"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<MultiSelectListPreference <MultiSelectListPreference
android:defaultValue="@array/values_list_badges" android:defaultValue="@array/values_list_badges"
android:entries="@array/list_badges" android:entries="@array/list_badges"
android:entryValues="@array/values_list_badges" android:entryValues="@array/values_list_badges"
android:key="manga_list_badges" android:key="manga_list_badges"
android:title="@string/badges_in_lists" /> android:title="@string/badges_in_lists" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/details"> <PreferenceCategory android:title="@string/details">
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="description_collapse" android:key="description_collapse"
android:title="@string/collapse_long_description" /> android:title="@string/collapse_long_description" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="pages_tab" android:key="pages_tab"
android:summary="@string/show_pages_thumbs_summary" android:summary="@string/show_pages_thumbs_summary"
android:title="@string/show_pages_thumbs" /> android:title="@string/show_pages_thumbs" />
<ListPreference <ListPreference
android:defaultValue="-1" android:defaultValue="-1"
android:dependency="pages_tab" android:dependency="pages_tab"
android:entries="@array/details_tabs" android:entries="@array/details_tabs"
android:entryValues="@array/details_tabs_values" android:entryValues="@array/details_tabs_values"
android:key="details_tab" android:key="details_tab"
android:title="@string/default_tab" android:title="@string/default_tab"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/main_screen"> <PreferenceCategory android:title="@string/main_screen">
<PreferenceScreen <MultiSelectListPreference
android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment" android:key="search_suggest_types"
android:key="nav_main" android:title="@string/search_suggestions" />
android:title="@string/main_screen_sections" />
<PreferenceScreen
<SwitchPreferenceCompat android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment"
android:defaultValue="true" android:key="nav_main"
android:key="main_fab" android:title="@string/main_screen_sections" />
android:summary="@string/main_screen_fab_summary"
android:title="@string/main_screen_fab" /> <SwitchPreferenceCompat
android:defaultValue="true"
<SwitchPreferenceCompat android:key="main_fab"
android:defaultValue="true" android:summary="@string/main_screen_fab_summary"
android:key="nav_labels" android:title="@string/main_screen_fab" />
android:title="@string/show_labels_in_navbar" />
<SwitchPreferenceCompat
<SwitchPreferenceCompat android:defaultValue="true"
android:defaultValue="false" android:key="nav_labels"
android:key="nav_pinned" android:title="@string/show_labels_in_navbar" />
android:summary="@string/pin_navigation_ui_summary"
android:title="@string/pin_navigation_ui" /> <SwitchPreferenceCompat
android:defaultValue="false"
<SwitchPreferenceCompat android:key="nav_pinned"
android:defaultValue="false" android:summary="@string/pin_navigation_ui_summary"
android:key="exit_confirm" android:title="@string/pin_navigation_ui" />
android:summary="@string/exit_confirmation_summary"
android:title="@string/exit_confirmation" /> <SwitchPreferenceCompat
android:defaultValue="false"
</PreferenceCategory> android:key="exit_confirm"
android:summary="@string/exit_confirmation_summary"
android:title="@string/exit_confirmation" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dynamic_shortcuts"
android:summary="@string/history_shortcuts_summary"
android:title="@string/history_shortcuts" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/privacy">
<SwitchPreferenceCompat
android:key="protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"
android:title="@string/protect_application" />
<ListPreference
android:defaultValue="allow"
android:entries="@array/screenshots_policy"
android:key="screenshots_policy"
android:title="@string/screenshots_policy"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/backup_restore">
<Preference
android:key="backup"
android:persistent="false"
android:summary="@string/backup_information"
android:title="@string/create_backup" />
<Preference
android:key="restore"
android:persistent="false"
android:summary="@string/restore_summary"
android:title="@string/restore_backup" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceScreen>

@ -2,9 +2,7 @@
<PreferenceScreen <PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/storage_usage"> android:title="@string/data_removal">
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
<Preference <Preference
android:key="search_history_clear" android:key="search_history_clear"

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/network">
<ListPreference
android:defaultValue="0"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="prefetch_content"
android:title="@string/prefetch_content"
app:useSimpleSummaryProvider="true"
tools:isPreferenceVisible="true" />
<ListPreference
android:defaultValue="2"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:useSimpleSummaryProvider="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
android:key="proxy"
android:title="@string/proxy"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"
android:key="doh"
android:title="@string/dns_over_https"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="adblock"
android:summary="@string/adblock_summary"
android:title="@string/adblock" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/image_proxies"
android:entryValues="@array/values_image_proxies"
android:key="images_proxy_2"
android:title="@string/images_proxy_title"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="mirror_switching"
android:summary="@string/mirror_switching_summary"
android:title="@string/mirror_switching" />
<SwitchPreferenceCompat
android:key="ssl_bypass"
android:summary="@string/ignore_ssl_errors_summary"
android:title="@string/ignore_ssl_errors" />
<SwitchPreferenceCompat
android:key="no_offline"
android:summary="@string/disable_connectivity_check_summary"
android:title="@string/disable_connectivity_check" />
</PreferenceScreen>

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/network">
<PreferenceCategory android:title="@string/storage_usage">
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
<Preference
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment"
android:persistent="false"
android:title="@string/data_removal" />
</PreferenceCategory>
<ListPreference
android:defaultValue="0"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="prefetch_content"
android:title="@string/prefetch_content"
app:allowDividerAbove="true"
app:useSimpleSummaryProvider="true"
tools:isPreferenceVisible="true" />
<ListPreference
android:defaultValue="2"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:useSimpleSummaryProvider="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
android:key="proxy"
android:title="@string/proxy"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"
android:key="doh"
android:title="@string/dns_over_https"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/image_proxies"
android:entryValues="@array/values_image_proxies"
android:key="images_proxy_2"
android:title="@string/images_proxy_title"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:key="ssl_bypass"
android:summary="@string/ignore_ssl_errors_summary"
android:title="@string/ignore_ssl_errors" />
<SwitchPreferenceCompat
android:key="no_offline"
android:summary="@string/disable_connectivity_check_summary"
android:title="@string/disable_connectivity_check" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="adblock"
android:summary="@string/adblock_summary"
android:title="@string/adblock" />
</PreferenceScreen>

@ -144,6 +144,12 @@
android:key="reader_bar_transparent" android:key="reader_bar_transparent"
android:title="@string/reader_info_bar_transparent" /> android:title="@string/reader_info_bar_transparent" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="reader_chapter_toast"
android:summary="@string/reader_chapter_toast_summary"
android:title="@string/reader_chapter_toast" />
<ListPreference <ListPreference
android:entries="@array/reader_backgrounds" android:entries="@array/reader_backgrounds"
android:key="reader_background" android:key="reader_background"

@ -1,59 +1,59 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen <PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.AppearanceSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.AppearanceSettingsFragment"
android:icon="@drawable/ic_appearance" android:icon="@drawable/ic_appearance"
android:key="appearance" android:key="appearance"
android:title="@string/appearance" /> android:title="@string/appearance" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment"
android:icon="@drawable/ic_manga_source" android:icon="@drawable/ic_manga_source"
android:key="remote_sources" android:key="remote_sources"
android:title="@string/remote_sources" /> android:title="@string/remote_sources" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ReaderSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.ReaderSettingsFragment"
android:icon="@drawable/ic_book_page" android:icon="@drawable/ic_book_page"
android:key="reader" android:key="reader"
android:title="@string/reader_settings" /> android:title="@string/reader_settings" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.NetworkSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment"
android:icon="@drawable/ic_web" android:icon="@drawable/ic_usage"
android:key="network" android:key="network"
android:title="@string/network" /> android:title="@string/storage_and_network" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.DownloadsSettingsFragment"
android:icon="@drawable/ic_data_privacy" android:icon="@drawable/ic_download"
android:key="userdata" android:key="downloads"
android:title="@string/data_and_privacy" /> android:title="@string/downloads" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.DownloadsSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment"
android:icon="@drawable/ic_download" android:icon="@drawable/ic_feed"
android:key="downloads" android:key="tracker"
android:title="@string/downloads" /> android:title="@string/check_for_new_chapters" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.ServicesSettingsFragment"
android:icon="@drawable/ic_feed" android:icon="@drawable/ic_services"
android:key="tracker" android:key="services"
android:title="@string/check_for_new_chapters" /> android:title="@string/services" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ServicesSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment"
android:icon="@drawable/ic_services" android:icon="@drawable/ic_backup_restore"
android:key="services" android:key="userdata"
android:title="@string/services" /> android:title="@string/backup_restore" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.about.AboutSettingsFragment" android:fragment="org.koitharu.kotatsu.settings.about.AboutSettingsFragment"
android:icon="@drawable/ic_info_outline" android:icon="@drawable/ic_info_outline"
android:key="about" android:key="about"
android:title="@string/about" /> android:title="@string/about" />
</PreferenceScreen> </PreferenceScreen>

@ -1,54 +1,66 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen <androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/remote_sources"> android:title="@string/remote_sources">
<ListPreference <ListPreference
android:key="sources_sort_order" android:key="sources_sort_order"
android:title="@string/sort_order" android:title="@string/sort_order"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="sources_grid" android:key="sources_grid"
android:title="@string/show_in_grid_view" /> android:title="@string/show_in_grid_view" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment" android:fragment="org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment"
android:key="remote_sources" android:key="remote_sources"
android:persistent="false" android:persistent="false"
android:title="@string/manage_sources" /> android:title="@string/manage_sources" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:key="sources_enabled_all" android:key="sources_enabled_all"
android:summary="@string/enable_all_sources_summary" android:summary="@string/enable_all_sources_summary"
android:title="@string/enable_all_sources" android:title="@string/enable_all_sources"
app:allowDividerAbove="true" /> app:allowDividerAbove="true" />
<Preference <Preference
android:key="sources_catalog" android:key="sources_catalog"
android:persistent="false" android:persistent="false"
android:title="@string/sources_catalog" /> android:title="@string/sources_catalog" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:key="no_nsfw" android:key="no_nsfw"
android:summary="@string/disable_nsfw_summary" android:summary="@string/disable_nsfw_summary"
android:title="@string/disable_nsfw" /> android:title="@string/disable_nsfw" />
<SwitchPreferenceCompat <ListPreference
android:defaultValue="true" android:entries="@array/incognito_nsfw_options"
android:key="tags_warnings" android:key="incognito_nsfw"
android:summary="@string/tags_warnings_summary" android:title="@string/incognito_for_nsfw"
android:title="@string/tags_warnings" /> app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:key="handle_links" android:defaultValue="true"
android:persistent="false" android:key="tags_warnings"
android:summary="@string/handle_links_summary" android:summary="@string/tags_warnings_summary"
android:title="@string/handle_links" android:title="@string/tags_warnings" />
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="mirror_switching"
android:summary="@string/mirror_switching_summary"
android:title="@string/mirror_switching"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:key="handle_links"
android:persistent="false"
android:summary="@string/handle_links_summary"
android:title="@string/handle_links" />
</androidx.preference.PreferenceScreen> </androidx.preference.PreferenceScreen>

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/data_and_privacy">
<SwitchPreferenceCompat
android:key="protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"
android:title="@string/protect_application" />
<ListPreference
android:defaultValue="allow"
android:entries="@array/screenshots_policy"
android:key="screenshots_policy"
android:title="@string/screenshots_policy"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/incognito_nsfw_options"
android:key="incognito_nsfw"
android:title="@string/incognito_for_nsfw"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dynamic_shortcuts"
android:summary="@string/history_shortcuts_summary"
android:title="@string/history_shortcuts" />
<MultiSelectListPreference
android:key="search_suggest_types"
android:title="@string/search_suggestions" />
<PreferenceCategory android:title="@string/backup_restore">
<Preference
android:key="backup"
android:persistent="false"
android:summary="@string/backup_information"
android:title="@string/create_backup" />
<Preference
android:key="restore"
android:persistent="false"
android:summary="@string/restore_summary"
android:title="@string/restore_backup" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceCategory>
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment"
android:key="storage_usage"
android:title="@string/storage_usage"
app:allowDividerAbove="true" />
</PreferenceScreen>

@ -35,7 +35,7 @@ material = "1.14.0-alpha05"
moshi = "1.15.2" moshi = "1.15.2"
okhttp = "5.2.1" okhttp = "5.2.1"
okio = "3.16.1" okio = "3.16.1"
parsers = "8908031eee" parsers = "df1cab3f9d"
preference = "1.2.1" preference = "1.2.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
room = "2.7.2" room = "2.7.2"

Loading…
Cancel
Save