mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 07:41:51 +01:00
Full Compose settings (#8201)
* Uses Voyager for navigation. * Replaces every screen inside settings except category editor screen since it's called from several places.
This commit is contained in:
parent
3fdcd636d7
commit
890f1a3c7b
@ -141,12 +141,12 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
sqldelight {
|
sqldelight {
|
||||||
@ -178,6 +178,7 @@ dependencies {
|
|||||||
implementation(compose.accompanist.flowlayout)
|
implementation(compose.accompanist.flowlayout)
|
||||||
implementation(compose.accompanist.pager.core)
|
implementation(compose.accompanist.pager.core)
|
||||||
implementation(compose.accompanist.pager.indicators)
|
implementation(compose.accompanist.pager.indicators)
|
||||||
|
implementation(compose.accompanist.permissions)
|
||||||
|
|
||||||
implementation(androidx.paging.runtime)
|
implementation(androidx.paging.runtime)
|
||||||
implementation(androidx.paging.compose)
|
implementation(androidx.paging.compose)
|
||||||
@ -264,6 +265,9 @@ dependencies {
|
|||||||
implementation(libs.markwon)
|
implementation(libs.markwon)
|
||||||
implementation(libs.aboutLibraries.compose)
|
implementation(libs.aboutLibraries.compose)
|
||||||
implementation(libs.cascade)
|
implementation(libs.cascade)
|
||||||
|
implementation(libs.numberpicker)
|
||||||
|
implementation(libs.bundles.voyager)
|
||||||
|
implementation(libs.materialmotion.core)
|
||||||
|
|
||||||
// Conductor
|
// Conductor
|
||||||
implementation(libs.bundles.conductor)
|
implementation(libs.bundles.conductor)
|
||||||
@ -315,10 +319,12 @@ tasks {
|
|||||||
kotlinOptions.freeCompilerArgs += listOf(
|
kotlinOptions.freeCompilerArgs += listOf(
|
||||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||||
"-opt-in=com.google.accompanist.pager.ExperimentalPagerApi",
|
"-opt-in=com.google.accompanist.pager.ExperimentalPagerApi",
|
||||||
|
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
|
||||||
"-opt-in=androidx.compose.material.ExperimentalMaterialApi",
|
"-opt-in=androidx.compose.material.ExperimentalMaterialApi",
|
||||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||||
|
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||||
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
||||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
|
@ -23,4 +23,6 @@ class BasePreferences(
|
|||||||
"extension_installer",
|
"extension_installer",
|
||||||
if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER,
|
if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,168 @@
|
|||||||
|
package eu.kanade.presentation.more.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.structuralEqualityPolicy
|
||||||
|
import eu.kanade.domain.track.service.TrackPreferences
|
||||||
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
|
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.EditTextPreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.ListPreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.MultiSelectListPreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||||
|
import eu.kanade.presentation.more.settings.widget.TrackingPreferenceWidget
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusWrapper(
|
||||||
|
item: Preference.PreferenceItem<*>,
|
||||||
|
highlightKey: String?,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val enabled = item.enabled
|
||||||
|
val highlighted = item.title == highlightKey
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = enabled,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
content = {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalPreferenceHighlighted provides highlighted,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PreferenceItem(
|
||||||
|
item: Preference.PreferenceItem<*>,
|
||||||
|
highlightKey: String?,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
StatusWrapper(
|
||||||
|
item = item,
|
||||||
|
highlightKey = highlightKey,
|
||||||
|
) {
|
||||||
|
when (item) {
|
||||||
|
is Preference.PreferenceItem.SwitchPreference -> {
|
||||||
|
val value by item.pref.collectAsState()
|
||||||
|
SwitchPreferenceWidget(
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
icon = item.icon,
|
||||||
|
checked = value,
|
||||||
|
onCheckedChanged = { newValue ->
|
||||||
|
scope.launch {
|
||||||
|
if (item.onValueChanged(newValue)) {
|
||||||
|
item.pref.set(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.ListPreference<*> -> {
|
||||||
|
val value by item.pref.collectAsState()
|
||||||
|
ListPreferenceWidget(
|
||||||
|
value = value,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
icon = item.icon,
|
||||||
|
entries = item.entries,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
scope.launch {
|
||||||
|
if (item.internalOnValueChanged(newValue!!)) {
|
||||||
|
item.internalSet(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.BasicListPreference -> {
|
||||||
|
ListPreferenceWidget(
|
||||||
|
value = item.value,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
icon = item.icon,
|
||||||
|
entries = item.entries,
|
||||||
|
onValueChange = { scope.launch { item.onValueChanged(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.MultiSelectListPreference -> {
|
||||||
|
val values by item.pref.collectAsState()
|
||||||
|
MultiSelectListPreferenceWidget(
|
||||||
|
preference = item,
|
||||||
|
values = values,
|
||||||
|
onValuesChange = { newValues ->
|
||||||
|
scope.launch {
|
||||||
|
if (item.onValueChanged(newValues)) {
|
||||||
|
item.pref.set(newValues.toMutableSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.TextPreference -> {
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
icon = item.icon,
|
||||||
|
onPreferenceClick = item.onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.EditTextPreference -> {
|
||||||
|
val values by item.pref.collectAsState()
|
||||||
|
EditTextPreferenceWidget(
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.subtitle,
|
||||||
|
icon = item.icon,
|
||||||
|
value = values,
|
||||||
|
onConfirm = {
|
||||||
|
val accepted = item.onValueChanged(it)
|
||||||
|
if (accepted) item.pref.set(it)
|
||||||
|
accepted
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.AppThemePreference -> {
|
||||||
|
val value by item.pref.collectAsState()
|
||||||
|
val amoled by Injekt.get<UiPreferences>().themeDarkAmoled().collectAsState()
|
||||||
|
AppThemePreferenceWidget(
|
||||||
|
title = item.title,
|
||||||
|
value = value,
|
||||||
|
amoled = amoled,
|
||||||
|
onItemClick = { scope.launch { item.pref.set(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem.TrackingPreference -> {
|
||||||
|
val uName by Injekt.get<PreferenceStore>()
|
||||||
|
.getString(TrackPreferences.trackUsername(item.service.id))
|
||||||
|
.collectAsState()
|
||||||
|
item.service.run {
|
||||||
|
TrackingPreferenceWidget(
|
||||||
|
title = item.title,
|
||||||
|
logoRes = getLogo(),
|
||||||
|
logoColor = getLogoColor(),
|
||||||
|
checked = uName.isNotEmpty(),
|
||||||
|
onClick = { if (isLogged) item.logout() else item.login() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,146 @@
|
|||||||
|
package eu.kanade.presentation.more.settings
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Info
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import eu.kanade.domain.ui.model.AppTheme
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.core.preference.Preference as PreferenceData
|
||||||
|
|
||||||
|
sealed class Preference {
|
||||||
|
abstract val title: String
|
||||||
|
abstract val enabled: Boolean
|
||||||
|
|
||||||
|
sealed class PreferenceItem<T> : Preference() {
|
||||||
|
abstract val subtitle: String?
|
||||||
|
abstract val icon: ImageVector?
|
||||||
|
abstract val onValueChanged: suspend (newValue: T) -> Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic [PreferenceItem] that only displays texts.
|
||||||
|
*/
|
||||||
|
data class TextPreference(
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = null,
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||||
|
|
||||||
|
val onClick: (() -> Unit)? = null,
|
||||||
|
) : PreferenceItem<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] that provides a two-state toggleable option.
|
||||||
|
*/
|
||||||
|
data class SwitchPreference(
|
||||||
|
val pref: PreferenceData<Boolean>,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = null,
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: Boolean) -> Boolean = { true },
|
||||||
|
) : PreferenceItem<Boolean>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
data class ListPreference<T>(
|
||||||
|
val pref: PreferenceData<T>,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = "%s",
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: T) -> Boolean = { true },
|
||||||
|
|
||||||
|
val entries: Map<T, String>,
|
||||||
|
) : PreferenceItem<T>() {
|
||||||
|
internal fun internalSet(newValue: Any) = pref.set(newValue as T)
|
||||||
|
internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [ListPreference] but with no connection to a [PreferenceData]
|
||||||
|
*/
|
||||||
|
data class BasicListPreference(
|
||||||
|
val value: String,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = "%s",
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||||
|
|
||||||
|
val entries: Map<String, String>,
|
||||||
|
) : PreferenceItem<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||||
|
* Multiple entries can be selected at the same time.
|
||||||
|
*/
|
||||||
|
data class MultiSelectListPreference(
|
||||||
|
val pref: PreferenceData<Set<String>>,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = null,
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true },
|
||||||
|
|
||||||
|
val entries: Map<String, String>,
|
||||||
|
) : PreferenceItem<Set<String>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] that shows a EditText in the dialog.
|
||||||
|
*/
|
||||||
|
data class EditTextPreference(
|
||||||
|
val pref: PreferenceData<String>,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String? = "%s",
|
||||||
|
override val icon: ImageVector? = null,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||||
|
) : PreferenceItem<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] that shows previews of [AppTheme] selection.
|
||||||
|
*/
|
||||||
|
data class AppThemePreference(
|
||||||
|
val pref: PreferenceData<AppTheme>,
|
||||||
|
override val title: String,
|
||||||
|
) : PreferenceItem<AppTheme>() {
|
||||||
|
override val enabled: Boolean = true
|
||||||
|
override val subtitle: String? = null
|
||||||
|
override val icon: ImageVector? = null
|
||||||
|
override val onValueChanged: suspend (newValue: AppTheme) -> Boolean = { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PreferenceItem] for individual tracking service.
|
||||||
|
*/
|
||||||
|
data class TrackingPreference(
|
||||||
|
val service: TrackService,
|
||||||
|
override val title: String,
|
||||||
|
val login: () -> Unit,
|
||||||
|
val logout: () -> Unit,
|
||||||
|
) : PreferenceItem<String>() {
|
||||||
|
override val enabled: Boolean = true
|
||||||
|
override val subtitle: String? = null
|
||||||
|
override val icon: ImageVector? = null
|
||||||
|
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PreferenceGroup(
|
||||||
|
override val title: String,
|
||||||
|
override val enabled: Boolean = true,
|
||||||
|
|
||||||
|
val preferenceItems: List<PreferenceItem<out Any>>,
|
||||||
|
) : Preference()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun infoPreference(info: String) = PreferenceItem.TextPreference(
|
||||||
|
title = "",
|
||||||
|
subtitle = info,
|
||||||
|
icon = Icons.Outlined.Info,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package eu.kanade.presentation.more.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import eu.kanade.presentation.components.AppBar
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceScaffold(
|
||||||
|
title: String,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
itemsProvider: @Composable () -> List<Preference>,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = { scrollBehavior ->
|
||||||
|
AppBar(
|
||||||
|
title = title,
|
||||||
|
navigateUp = onBackPressed,
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { contentPadding ->
|
||||||
|
PreferenceScreen(
|
||||||
|
items = itemsProvider(),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
package eu.kanade.presentation.more.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.fastForEachIndexed
|
||||||
|
import eu.kanade.presentation.components.Divider
|
||||||
|
import eu.kanade.presentation.components.ScrollbarLazyColumn
|
||||||
|
import eu.kanade.presentation.more.settings.screen.SearchableSettings
|
||||||
|
import eu.kanade.presentation.more.settings.widget.PreferenceGroupHeader
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preference Screen composable which contains a list of [Preference] items
|
||||||
|
* @param items [Preference] items which should be displayed on the preference screen. An item can be a single [PreferenceItem] or a group ([Preference.PreferenceGroup])
|
||||||
|
* @param modifier [Modifier] to be applied to the preferenceScreen layout
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PreferenceScreen(
|
||||||
|
items: List<Preference>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
val highlightKey = SearchableSettings.highlightKey
|
||||||
|
if (highlightKey != null) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val i = items.findHighlightedIndex(highlightKey)
|
||||||
|
if (i >= 0) {
|
||||||
|
delay(500)
|
||||||
|
state.animateScrollToItem(i)
|
||||||
|
}
|
||||||
|
SearchableSettings.highlightKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollbarLazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
) {
|
||||||
|
items.fastForEachIndexed { i, preference ->
|
||||||
|
when (preference) {
|
||||||
|
// Create Preference Group
|
||||||
|
is Preference.PreferenceGroup -> {
|
||||||
|
if (!preference.enabled) return@fastForEachIndexed
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column {
|
||||||
|
if (i != 0) {
|
||||||
|
Divider(modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
}
|
||||||
|
PreferenceGroupHeader(title = preference.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(preference.preferenceItems) { item ->
|
||||||
|
PreferenceItem(
|
||||||
|
item = item,
|
||||||
|
highlightKey = highlightKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Preference Item
|
||||||
|
is Preference.PreferenceItem<*> -> item {
|
||||||
|
PreferenceItem(
|
||||||
|
item = preference,
|
||||||
|
highlightKey = highlightKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Preference>.findHighlightedIndex(highlightKey: String): Int {
|
||||||
|
return flatMap {
|
||||||
|
if (it is Preference.PreferenceGroup) {
|
||||||
|
mutableListOf<String?>()
|
||||||
|
.apply {
|
||||||
|
add(null) // Header
|
||||||
|
addAll(it.preferenceItems.map { groupItem -> groupItem.title })
|
||||||
|
add(null) // Spacer
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listOf(it.title)
|
||||||
|
}
|
||||||
|
}.indexOfFirst { it == highlightKey }
|
||||||
|
}
|
@ -0,0 +1,218 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.FlipToBack
|
||||||
|
import androidx.compose.material.icons.outlined.SelectAll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
|
import cafe.adriel.voyager.core.model.coroutineScope
|
||||||
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.domain.source.interactor.GetSourcesWithNonLibraryManga
|
||||||
|
import eu.kanade.domain.source.model.Source
|
||||||
|
import eu.kanade.domain.source.model.SourceWithCount
|
||||||
|
import eu.kanade.presentation.components.AppBar
|
||||||
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
|
import eu.kanade.presentation.components.Divider
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.FastScrollLazyColumn
|
||||||
|
import eu.kanade.presentation.components.LoadingScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.more.settings.database.components.ClearDatabaseDeleteDialog
|
||||||
|
import eu.kanade.presentation.more.settings.database.components.ClearDatabaseItem
|
||||||
|
import eu.kanade.tachiyomi.Database
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class ClearDatabaseScreen : Screen {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val model = rememberScreenModel { ClearDatabaseScreenModel() }
|
||||||
|
val state by model.state.collectAsState()
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
is ClearDatabaseScreenModel.State.Loading -> LoadingScreen()
|
||||||
|
is ClearDatabaseScreenModel.State.Ready -> {
|
||||||
|
if (s.showConfirmation) {
|
||||||
|
ClearDatabaseDeleteDialog(
|
||||||
|
onDismissRequest = model::hideConfirmation,
|
||||||
|
onDelete = {
|
||||||
|
model.removeMangaBySourceId()
|
||||||
|
model.clearSelection()
|
||||||
|
model.hideConfirmation()
|
||||||
|
context.toast(R.string.clear_database_completed)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = { scrollBehavior ->
|
||||||
|
AppBar(
|
||||||
|
title = stringResource(R.string.pref_clear_database),
|
||||||
|
navigateUp = navigator::pop,
|
||||||
|
actions = {
|
||||||
|
if (s.items.isNotEmpty()) {
|
||||||
|
AppBarActions(
|
||||||
|
actions = listOf(
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.action_select_all),
|
||||||
|
icon = Icons.Outlined.SelectAll,
|
||||||
|
onClick = model::selectAll,
|
||||||
|
),
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.action_select_all),
|
||||||
|
icon = Icons.Outlined.FlipToBack,
|
||||||
|
onClick = model::invertSelection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
if (s.items.isEmpty()) {
|
||||||
|
EmptyScreen(
|
||||||
|
message = stringResource(R.string.database_clean),
|
||||||
|
modifier = Modifier.padding(contentPadding),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
FastScrollLazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
items(s.items) { sourceWithCount ->
|
||||||
|
ClearDatabaseItem(
|
||||||
|
source = sourceWithCount.source,
|
||||||
|
count = sourceWithCount.count,
|
||||||
|
isSelected = s.selection.contains(sourceWithCount.id),
|
||||||
|
onClickSelect = { model.toggleSelection(sourceWithCount.source) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
onClick = model::showConfirmation,
|
||||||
|
enabled = s.selection.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_delete),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ClearDatabaseScreenModel : StateScreenModel<ClearDatabaseScreenModel.State>(State.Loading) {
|
||||||
|
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga = Injekt.get()
|
||||||
|
private val database: Database = Injekt.get()
|
||||||
|
|
||||||
|
init {
|
||||||
|
coroutineScope.launchIO {
|
||||||
|
getSourcesWithNonLibraryManga.subscribe()
|
||||||
|
.collectLatest { list ->
|
||||||
|
mutableState.update { old ->
|
||||||
|
val items = list.sortedBy { it.name }
|
||||||
|
when (old) {
|
||||||
|
State.Loading -> State.Ready(items)
|
||||||
|
is State.Ready -> old.copy(items = items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeMangaBySourceId() {
|
||||||
|
val state = state.value as? State.Ready ?: return
|
||||||
|
database.mangasQueries.deleteMangasNotInLibraryBySourceIds(state.selection)
|
||||||
|
database.historyQueries.removeResettedHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSelection(source: Source) = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
val mutableList = state.selection.toMutableList()
|
||||||
|
if (mutableList.contains(source.id)) {
|
||||||
|
mutableList.remove(source.id)
|
||||||
|
} else {
|
||||||
|
mutableList.add(source.id)
|
||||||
|
}
|
||||||
|
state.copy(selection = mutableList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
state.copy(selection = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAll() = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
state.copy(selection = state.items.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invertSelection() = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
state.copy(
|
||||||
|
selection = state.items
|
||||||
|
.map { it.id }
|
||||||
|
.filterNot { it in state.selection },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showConfirmation() = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
state.copy(showConfirmation = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideConfirmation() = mutableState.update { state ->
|
||||||
|
if (state !is State.Ready) return@update state
|
||||||
|
state.copy(showConfirmation = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class State {
|
||||||
|
object Loading : State()
|
||||||
|
data class Ready(
|
||||||
|
val items: List<SourceWithCount>,
|
||||||
|
val selection: List<Long> = emptyList(),
|
||||||
|
val showConfirmation: Boolean = false,
|
||||||
|
) : State()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.presentation.category.visualName
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a string of categories name for settings subtitle
|
||||||
|
*/
|
||||||
|
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
fun getCategoriesLabel(
|
||||||
|
allCategories: List<Category>,
|
||||||
|
included: Set<String>,
|
||||||
|
excluded: Set<String>,
|
||||||
|
): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val includedCategories = included
|
||||||
|
.mapNotNull { id -> allCategories.find { it.id == id.toLong() } }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
val excludedCategories = excluded
|
||||||
|
.mapNotNull { id -> allCategories.find { it.id == id.toLong() } }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
val allExcluded = excludedCategories.size == allCategories.size
|
||||||
|
|
||||||
|
val includedItemsText = when {
|
||||||
|
// Some selected, but not all
|
||||||
|
includedCategories.isNotEmpty() && includedCategories.size != allCategories.size -> includedCategories.joinToString { it.visualName(context) }
|
||||||
|
// All explicitly selected
|
||||||
|
includedCategories.size == allCategories.size -> stringResource(R.string.all)
|
||||||
|
allExcluded -> stringResource(R.string.none)
|
||||||
|
else -> stringResource(R.string.all)
|
||||||
|
}
|
||||||
|
val excludedItemsText = when {
|
||||||
|
excludedCategories.isEmpty() -> stringResource(R.string.none)
|
||||||
|
allExcluded -> stringResource(R.string.all)
|
||||||
|
else -> excludedCategories.joinToString { it.visualName(context) }
|
||||||
|
}
|
||||||
|
return stringResource(id = R.string.include, includedItemsText) + "\n" +
|
||||||
|
stringResource(id = R.string.exclude, excludedItemsText)
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
||||||
|
import eu.kanade.presentation.util.LocalBackPress
|
||||||
|
|
||||||
|
interface SearchableSettings : Screen {
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun getTitle(): String
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getPreferences(): List<Preference>
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RowScope.AppBarAction() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val handleBack = LocalBackPress.currentOrThrow
|
||||||
|
PreferenceScaffold(
|
||||||
|
title = getTitle(),
|
||||||
|
onBackPressed = handleBack::invoke,
|
||||||
|
actions = { AppBarAction() },
|
||||||
|
itemsProvider = { getPreferences() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// HACK: for the background blipping thingy.
|
||||||
|
// The title of the target PreferenceItem
|
||||||
|
// Set before showing the destination screen and reset after
|
||||||
|
// See BasePreferenceWidget.highlightBackground
|
||||||
|
var highlightKey: String? = null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,398 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.webkit.WebStorage
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
|
import eu.kanade.domain.library.service.LibraryPreferences
|
||||||
|
import eu.kanade.domain.manga.repository.MangaRepository
|
||||||
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
|
import eu.kanade.domain.ui.model.TabletUiMode
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_360
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_CONTROLD
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_DNSPOD
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_MULLVAD
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_NJALLA
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
|
||||||
|
import eu.kanade.tachiyomi.util.CrashLogUtil
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
|
import eu.kanade.tachiyomi.util.system.isDevFlavor
|
||||||
|
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||||
|
import eu.kanade.tachiyomi.util.system.logcat
|
||||||
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
|
import eu.kanade.tachiyomi.util.system.powerManager
|
||||||
|
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import logcat.LogPriority
|
||||||
|
import rikka.sui.Sui
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SettingsAdvancedScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_advanced)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val basePreferences = remember { Injekt.get<BasePreferences>() }
|
||||||
|
val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = basePreferences.acraEnabled(),
|
||||||
|
title = stringResource(id = R.string.pref_enable_acra),
|
||||||
|
subtitle = stringResource(id = R.string.pref_acra_summary),
|
||||||
|
enabled = !isDevFlavor,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_dump_crash_logs),
|
||||||
|
subtitle = stringResource(id = R.string.pref_dump_crash_logs_summary),
|
||||||
|
onClick = {
|
||||||
|
scope.launchNonCancellable {
|
||||||
|
CrashLogUtil(context).dumpLogs()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = networkPreferences.verboseLogging(),
|
||||||
|
title = stringResource(id = R.string.pref_verbose_logging),
|
||||||
|
subtitle = stringResource(id = R.string.pref_verbose_logging_summary),
|
||||||
|
onValueChanged = {
|
||||||
|
context.toast(R.string.requires_app_restart)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
getBackgroundActivityGroup(),
|
||||||
|
getDataGroup(),
|
||||||
|
getNetworkGroup(networkPreferences = networkPreferences),
|
||||||
|
getLibraryGroup(),
|
||||||
|
getExtensionsGroup(basePreferences = basePreferences),
|
||||||
|
getDisplayGroup(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getBackgroundActivityGroup(): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_background_activity),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_disable_battery_optimization),
|
||||||
|
subtitle = stringResource(id = R.string.pref_disable_battery_optimization_summary),
|
||||||
|
onClick = {
|
||||||
|
val packageName: String = context.packageName
|
||||||
|
if (!context.powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
try {
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
val intent = Intent().apply {
|
||||||
|
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||||
|
data = "package:$packageName".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
context.toast(R.string.battery_optimization_setting_activity_not_found)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.toast(R.string.battery_optimization_disabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = "Don't kill my app!",
|
||||||
|
subtitle = stringResource(id = R.string.about_dont_kill_my_app),
|
||||||
|
onClick = { context.openInBrowser("https://dontkillmyapp.com/") },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDataGroup(): Preference.PreferenceGroup {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
|
||||||
|
|
||||||
|
val chapterCache = remember { Injekt.get<ChapterCache>() }
|
||||||
|
var readableSizeSema by remember { mutableStateOf(0) }
|
||||||
|
val readableSize = remember(readableSizeSema) { chapterCache.readableSize }
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_data),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_clear_chapter_cache),
|
||||||
|
subtitle = stringResource(id = R.string.used_cache, readableSize),
|
||||||
|
onClick = {
|
||||||
|
scope.launchNonCancellable {
|
||||||
|
try {
|
||||||
|
val deletedFiles = chapterCache.clear()
|
||||||
|
withUIContext {
|
||||||
|
context.toast(context.getString(R.string.cache_deleted, deletedFiles))
|
||||||
|
readableSizeSema++
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
withUIContext { context.toast(R.string.cache_delete_error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = libraryPreferences.autoClearChapterCache(),
|
||||||
|
title = stringResource(id = R.string.pref_auto_clear_chapter_cache),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_clear_database),
|
||||||
|
subtitle = stringResource(id = R.string.pref_clear_database_summary),
|
||||||
|
onClick = { navigator.push(ClearDatabaseScreen()) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getNetworkGroup(
|
||||||
|
networkPreferences: NetworkPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val networkHelper = remember { Injekt.get<NetworkHelper>() }
|
||||||
|
|
||||||
|
val userAgentPref = networkPreferences.defaultUserAgent()
|
||||||
|
val userAgent by userAgentPref.collectAsState()
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_network),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_clear_cookies),
|
||||||
|
onClick = {
|
||||||
|
networkHelper.cookieManager.removeAll()
|
||||||
|
context.toast(R.string.cookies_cleared)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_clear_webview_data),
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
WebView(context).run {
|
||||||
|
setDefaultSettings()
|
||||||
|
clearCache(true)
|
||||||
|
clearFormData()
|
||||||
|
clearHistory()
|
||||||
|
clearSslPreferences()
|
||||||
|
}
|
||||||
|
WebStorage.getInstance().deleteAllData()
|
||||||
|
context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
|
||||||
|
context.toast(R.string.webview_data_deleted)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
context.toast(R.string.cache_delete_error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = networkPreferences.dohProvider(),
|
||||||
|
title = stringResource(id = R.string.pref_dns_over_https),
|
||||||
|
entries = mapOf(
|
||||||
|
-1 to stringResource(id = R.string.disabled),
|
||||||
|
PREF_DOH_CLOUDFLARE to "Cloudflare",
|
||||||
|
PREF_DOH_GOOGLE to "Google",
|
||||||
|
PREF_DOH_ADGUARD to "AdGuard",
|
||||||
|
PREF_DOH_QUAD9 to "Quad9",
|
||||||
|
PREF_DOH_ALIDNS to "AliDNS",
|
||||||
|
PREF_DOH_DNSPOD to "DNSPod",
|
||||||
|
PREF_DOH_360 to "360",
|
||||||
|
PREF_DOH_QUAD101 to "Quad 101",
|
||||||
|
PREF_DOH_MULLVAD to "Mullvad",
|
||||||
|
PREF_DOH_CONTROLD to "Control D",
|
||||||
|
PREF_DOH_NJALLA to "Njalla",
|
||||||
|
),
|
||||||
|
onValueChanged = {
|
||||||
|
context.toast(R.string.requires_app_restart)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.EditTextPreference(
|
||||||
|
pref = userAgentPref,
|
||||||
|
title = stringResource(id = R.string.pref_user_agent_string),
|
||||||
|
onValueChanged = {
|
||||||
|
if (it.isBlank()) {
|
||||||
|
context.toast(R.string.error_user_agent_string_blank)
|
||||||
|
return@EditTextPreference false
|
||||||
|
}
|
||||||
|
context.toast(R.string.requires_app_restart)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_reset_user_agent_string),
|
||||||
|
enabled = remember(userAgent) { userAgent != userAgentPref.defaultValue() },
|
||||||
|
onClick = {
|
||||||
|
userAgentPref.delete()
|
||||||
|
context.toast(R.string.requires_app_restart)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getLibraryGroup(): Preference.PreferenceGroup {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val trackManager = remember { Injekt.get<TrackManager>() }
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_library),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_refresh_library_covers),
|
||||||
|
onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.COVERS) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_refresh_library_tracking),
|
||||||
|
subtitle = stringResource(id = R.string.pref_refresh_library_tracking_summary),
|
||||||
|
enabled = trackManager.hasLoggedServices(),
|
||||||
|
onClick = { LibraryUpdateService.start(context, target = LibraryUpdateService.Target.TRACKING) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_reset_viewer_flags),
|
||||||
|
subtitle = stringResource(id = R.string.pref_reset_viewer_flags_summary),
|
||||||
|
onClick = {
|
||||||
|
scope.launchNonCancellable {
|
||||||
|
val success = Injekt.get<MangaRepository>().resetViewerFlags()
|
||||||
|
withUIContext {
|
||||||
|
val message = if (success) {
|
||||||
|
R.string.pref_reset_viewer_flags_success
|
||||||
|
} else {
|
||||||
|
R.string.pref_reset_viewer_flags_error
|
||||||
|
}
|
||||||
|
context.toast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getExtensionsGroup(
|
||||||
|
basePreferences: BasePreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var shizukuMissing by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (shizukuMissing) {
|
||||||
|
val dismiss = { shizukuMissing = false }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = dismiss,
|
||||||
|
title = { Text(text = stringResource(id = R.string.ext_installer_shizuku)) },
|
||||||
|
text = { Text(text = stringResource(id = R.string.ext_installer_shizuku_unavailable_dialog)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = dismiss) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
context.openInBrowser("https://shizuku.rikka.app/download")
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_extensions),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = basePreferences.extensionInstaller(),
|
||||||
|
title = stringResource(id = R.string.ext_installer_pref),
|
||||||
|
entries = PreferenceValues.ExtensionInstaller.values()
|
||||||
|
.run {
|
||||||
|
if (DeviceUtil.isMiui) {
|
||||||
|
filter { it != PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER }
|
||||||
|
} else {
|
||||||
|
toList()
|
||||||
|
}
|
||||||
|
}.associateWith { stringResource(id = it.titleResId) },
|
||||||
|
onValueChanged = {
|
||||||
|
if (it == PreferenceValues.ExtensionInstaller.SHIZUKU &&
|
||||||
|
!(context.isPackageInstalled("moe.shizuku.privileged.api") || Sui.isSui())
|
||||||
|
) {
|
||||||
|
shizukuMissing = true
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDisplayGroup(): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiPreferences = remember { Injekt.get<UiPreferences>() }
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_display),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = uiPreferences.tabletUiMode(),
|
||||||
|
title = stringResource(id = R.string.pref_tablet_ui_mode),
|
||||||
|
entries = TabletUiMode.values().associateWith { stringResource(id = it.titleResId) },
|
||||||
|
onValueChanged = {
|
||||||
|
context.toast(R.string.requires_app_restart)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,142 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
|
import eu.kanade.domain.ui.model.ThemeMode
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.util.system.isTablet
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class SettingsAppearanceScreen : SearchableSettings {
|
||||||
|
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_appearance)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiPreferences = remember { Injekt.get<UiPreferences>() }
|
||||||
|
val themeModePref = uiPreferences.themeMode()
|
||||||
|
val appThemePref = uiPreferences.appTheme()
|
||||||
|
val amoledPref = uiPreferences.themeDarkAmoled()
|
||||||
|
|
||||||
|
val themeMode by themeModePref.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
merge(appThemePref.changes(), amoledPref.changes())
|
||||||
|
.drop(2)
|
||||||
|
.collectLatest { (context as? Activity)?.let { ActivityCompat.recreate(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = themeModePref,
|
||||||
|
title = stringResource(id = R.string.pref_category_theme),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
mapOf(
|
||||||
|
ThemeMode.SYSTEM to stringResource(id = R.string.theme_system),
|
||||||
|
ThemeMode.LIGHT to stringResource(id = R.string.theme_light),
|
||||||
|
ThemeMode.DARK to stringResource(id = R.string.theme_dark),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
ThemeMode.LIGHT to stringResource(id = R.string.theme_light),
|
||||||
|
ThemeMode.DARK to stringResource(id = R.string.theme_dark),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.AppThemePreference(
|
||||||
|
title = stringResource(id = R.string.pref_app_theme),
|
||||||
|
pref = appThemePref,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = amoledPref,
|
||||||
|
title = stringResource(id = R.string.pref_dark_theme_pure_black),
|
||||||
|
enabled = themeMode != ThemeMode.LIGHT,
|
||||||
|
),
|
||||||
|
getNavigationGroup(context = context, uiPreferences = uiPreferences),
|
||||||
|
getTimestampGroup(uiPreferences = uiPreferences),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getNavigationGroup(
|
||||||
|
context: Context,
|
||||||
|
uiPreferences: UiPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_navigation),
|
||||||
|
enabled = remember(context) { context.isTablet() },
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = uiPreferences.sideNavIconAlignment(),
|
||||||
|
title = stringResource(id = R.string.pref_side_nav_icon_alignment),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = mapOf(
|
||||||
|
0 to stringResource(id = R.string.alignment_top),
|
||||||
|
1 to stringResource(id = R.string.alignment_center),
|
||||||
|
2 to stringResource(id = R.string.alignment_bottom),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getTimestampGroup(uiPreferences: UiPreferences): Preference.PreferenceGroup {
|
||||||
|
val now = remember { Date().time }
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_timestamps),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = uiPreferences.relativeTime(),
|
||||||
|
title = stringResource(id = R.string.pref_relative_format),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = mapOf(
|
||||||
|
0 to stringResource(id = R.string.off),
|
||||||
|
2 to stringResource(id = R.string.pref_relative_time_short),
|
||||||
|
7 to stringResource(id = R.string.pref_relative_time_long),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = uiPreferences.dateFormat(),
|
||||||
|
title = stringResource(id = R.string.pref_date_format),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = DateFormats
|
||||||
|
.associateWith {
|
||||||
|
val formattedDate = UiPreferences.dateFormat(it).format(now)
|
||||||
|
"${it.ifEmpty { stringResource(id = R.string.label_default) }} ($formattedDate)"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DateFormats = listOf(
|
||||||
|
"", // Default
|
||||||
|
"MM/dd/yy",
|
||||||
|
"dd/MM/yy",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"dd MMM yyyy",
|
||||||
|
"MMM dd, yyyy",
|
||||||
|
)
|
@ -0,0 +1,370 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.domain.backup.service.BackupPreferences
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupConst
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsBackupScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.label_backup)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val backupPreferences = Injekt.get<BackupPreferences>()
|
||||||
|
|
||||||
|
RequestStoragePermission()
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
getCreateBackupPref(),
|
||||||
|
getRestoreBackupPref(),
|
||||||
|
getAutomaticBackupGroup(backupPreferences = backupPreferences),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestStoragePermission() {
|
||||||
|
val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
permissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var flag by rememberSaveable { mutableStateOf(0) }
|
||||||
|
val chooseBackupDir = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/*"),
|
||||||
|
) {
|
||||||
|
if (it != null) {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
it,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
BackupCreatorJob.startNow(context, it, flag)
|
||||||
|
}
|
||||||
|
flag = 0
|
||||||
|
}
|
||||||
|
var showCreateDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (showCreateDialog) {
|
||||||
|
CreateBackupDialog(
|
||||||
|
onConfirm = {
|
||||||
|
showCreateDialog = false
|
||||||
|
flag = it
|
||||||
|
chooseBackupDir.launch(Backup.getBackupFilename())
|
||||||
|
},
|
||||||
|
onDismissRequest = { showCreateDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_create_backup),
|
||||||
|
subtitle = stringResource(id = R.string.pref_create_backup_summ),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (!BackupCreatorJob.isManualJobRunning(context)) {
|
||||||
|
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
||||||
|
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
showCreateDialog = true
|
||||||
|
} else {
|
||||||
|
context.toast(R.string.backup_in_progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateBackupDialog(
|
||||||
|
onConfirm: (flag: Int) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val flags = remember { mutableStateListOf<Int>() }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = stringResource(id = R.string.backup_choice)) },
|
||||||
|
text = {
|
||||||
|
val choices = remember {
|
||||||
|
mapOf(
|
||||||
|
BackupConst.BACKUP_CATEGORY to R.string.categories,
|
||||||
|
BackupConst.BACKUP_CHAPTER to R.string.chapters,
|
||||||
|
BackupConst.BACKUP_TRACK to R.string.track,
|
||||||
|
BackupConst.BACKUP_HISTORY to R.string.history,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
CreateBackupDialogItem(
|
||||||
|
isSelected = true,
|
||||||
|
title = stringResource(id = R.string.manga),
|
||||||
|
)
|
||||||
|
choices.forEach { (k, v) ->
|
||||||
|
val isSelected = flags.contains(k)
|
||||||
|
CreateBackupDialogItem(
|
||||||
|
isSelected = isSelected,
|
||||||
|
title = stringResource(id = v),
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
if (isSelected) {
|
||||||
|
flags.remove(k)
|
||||||
|
} else {
|
||||||
|
flags.add(k)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val flag = flags.fold(initial = 0, operation = { a, b -> a or b })
|
||||||
|
onConfirm(flag)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateBackupDialogItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isSelected: Boolean,
|
||||||
|
title: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp),
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.merge(),
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getRestoreBackupPref(): Preference.PreferenceItem.TextPreference {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var error by remember { mutableStateOf<Any?>(null) }
|
||||||
|
if (error != null) {
|
||||||
|
val onDismissRequest = { error = null }
|
||||||
|
when (val err = error) {
|
||||||
|
is InvalidRestore -> {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = stringResource(id = R.string.invalid_backup_file)) },
|
||||||
|
text = { Text(text = err.message) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(err.message))
|
||||||
|
context.toast(R.string.copied_to_clipboard)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MissingRestoreComponents -> {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = stringResource(id = R.string.pref_restore_backup)) },
|
||||||
|
text = {
|
||||||
|
var msg = stringResource(id = R.string.backup_restore_content_full)
|
||||||
|
if (err.sources.isNotEmpty()) {
|
||||||
|
msg += "\n\n${stringResource(R.string.backup_restore_missing_sources)}\n${err.sources.joinToString("\n") { "- $it" }}"
|
||||||
|
}
|
||||||
|
if (err.sources.isNotEmpty()) {
|
||||||
|
msg += "\n\n${stringResource(R.string.backup_restore_missing_trackers)}\n${err.trackers.joinToString("\n") { "- $it" }}"
|
||||||
|
}
|
||||||
|
Text(text = msg)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
BackupRestoreService.start(context, err.uri)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.action_restore))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> error = null // Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooseBackup = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
||||||
|
if (it != null) {
|
||||||
|
val results = try {
|
||||||
|
BackupFileValidator().validate(context, it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = InvalidRestore(e.message.toString())
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) {
|
||||||
|
BackupRestoreService.start(context, it)
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
error = MissingRestoreComponents(it, results.missingSources, results.missingTrackers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_restore_backup),
|
||||||
|
subtitle = stringResource(id = R.string.pref_restore_backup_summ),
|
||||||
|
onClick = {
|
||||||
|
if (!BackupRestoreService.isRunning(context)) {
|
||||||
|
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
||||||
|
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
chooseBackup.launch("*/*")
|
||||||
|
} else {
|
||||||
|
context.toast(R.string.restore_in_progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getAutomaticBackupGroup(
|
||||||
|
backupPreferences: BackupPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val backupDirPref = backupPreferences.backupsDirectory()
|
||||||
|
val backupDir by backupDirPref.collectAsState()
|
||||||
|
val pickBackupLocation = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
|
||||||
|
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
|
|
||||||
|
val file = UniFile.fromUri(context, uri)
|
||||||
|
backupDirPref.set(file.uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_backup_service_category),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = backupPreferences.backupInterval(),
|
||||||
|
title = stringResource(id = R.string.pref_backup_interval),
|
||||||
|
entries = mapOf(
|
||||||
|
6 to stringResource(id = R.string.update_6hour),
|
||||||
|
12 to stringResource(id = R.string.update_12hour),
|
||||||
|
24 to stringResource(id = R.string.update_24hour),
|
||||||
|
48 to stringResource(id = R.string.update_48hour),
|
||||||
|
168 to stringResource(id = R.string.update_weekly),
|
||||||
|
),
|
||||||
|
onValueChanged = {
|
||||||
|
BackupCreatorJob.setupTask(context, it)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_backup_directory),
|
||||||
|
subtitle = remember(backupDir) {
|
||||||
|
UniFile.fromUri(context, backupDir.toUri()).filePath!! + "/automatic"
|
||||||
|
},
|
||||||
|
onClick = { pickBackupLocation.launch(null) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = backupPreferences.numberOfBackups(),
|
||||||
|
title = stringResource(id = R.string.pref_backup_slots),
|
||||||
|
entries = listOf(2, 3, 4, 5).associateWith { it.toString() },
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.backup_info)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MissingRestoreComponents(
|
||||||
|
val uri: Uri,
|
||||||
|
val sources: List<String>,
|
||||||
|
val trackers: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InvalidRestore(
|
||||||
|
val message: String,
|
||||||
|
)
|
@ -0,0 +1,79 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsBrowseScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.browse)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
||||||
|
val preferences = remember { Injekt.get<BasePreferences>() }
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_sources),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = sourcePreferences.duplicatePinnedSources(),
|
||||||
|
title = stringResource(id = R.string.pref_duplicate_pinned_sources),
|
||||||
|
subtitle = stringResource(id = R.string.pref_duplicate_pinned_sources_summary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.label_extensions),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = preferences.automaticExtUpdates(),
|
||||||
|
title = stringResource(id = R.string.pref_enable_automatic_extension_updates),
|
||||||
|
onValueChanged = {
|
||||||
|
ExtensionUpdateJob.setupTask(context, it)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.action_global_search),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = sourcePreferences.searchPinnedSourcesOnly(),
|
||||||
|
title = stringResource(id = R.string.pref_search_pinned_sources_only),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_nsfw_content),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = sourcePreferences.showNsfwSource(),
|
||||||
|
title = stringResource(id = R.string.pref_show_nsfw_source),
|
||||||
|
subtitle = stringResource(id = R.string.requires_app_restart),
|
||||||
|
onValueChanged = {
|
||||||
|
(context as FragmentActivity).authenticate(
|
||||||
|
title = context.getString(R.string.pref_category_nsfw_content),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.parental_controls_info)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,269 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Environment
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.domain.download.service.DownloadPreferences
|
||||||
|
import eu.kanade.presentation.category.visualName
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.more.settings.widget.TriStateListDialog
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SettingsDownloadScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_downloads)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val getCategories = remember { Injekt.get<GetCategories>() }
|
||||||
|
val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() })
|
||||||
|
|
||||||
|
val downloadPreferences = remember { Injekt.get<DownloadPreferences>() }
|
||||||
|
return listOf(
|
||||||
|
getDownloadLocationPreference(downloadPreferences = downloadPreferences),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadPreferences.downloadOnlyOverWifi(),
|
||||||
|
title = stringResource(id = R.string.connected_to_wifi),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadPreferences.saveChaptersAsCBZ(),
|
||||||
|
title = stringResource(id = R.string.save_chapter_as_cbz),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadPreferences.splitTallImages(),
|
||||||
|
title = stringResource(id = R.string.split_tall_images),
|
||||||
|
subtitle = stringResource(id = R.string.split_tall_images_summary),
|
||||||
|
),
|
||||||
|
getDeleteChaptersGroup(
|
||||||
|
downloadPreferences = downloadPreferences,
|
||||||
|
categories = allCategories,
|
||||||
|
),
|
||||||
|
getDownloadNewChaptersGroup(
|
||||||
|
downloadPreferences = downloadPreferences,
|
||||||
|
allCategories = allCategories,
|
||||||
|
),
|
||||||
|
getDownloadAheadGroup(downloadPreferences = downloadPreferences),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDownloadLocationPreference(
|
||||||
|
downloadPreferences: DownloadPreferences,
|
||||||
|
): Preference.PreferenceItem.ListPreference<String> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentDirPref = downloadPreferences.downloadsDirectory()
|
||||||
|
val currentDir by currentDirPref.collectAsState()
|
||||||
|
|
||||||
|
val pickLocation = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
|
||||||
|
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
|
|
||||||
|
val file = UniFile.fromUri(context, uri)
|
||||||
|
currentDirPref.set(file.uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultDirPair = rememberDefaultDownloadDir()
|
||||||
|
val customDirEntryKey = currentDir.takeIf { it != defaultDirPair.first } ?: "custom"
|
||||||
|
|
||||||
|
return Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = currentDirPref,
|
||||||
|
title = stringResource(id = R.string.pref_download_directory),
|
||||||
|
subtitle = remember(currentDir) {
|
||||||
|
UniFile.fromUri(context, currentDir.toUri()).filePath!!
|
||||||
|
},
|
||||||
|
entries = mapOf(
|
||||||
|
defaultDirPair,
|
||||||
|
customDirEntryKey to stringResource(id = R.string.custom_dir),
|
||||||
|
),
|
||||||
|
onValueChanged = {
|
||||||
|
val default = it == defaultDirPair.first
|
||||||
|
if (!default) {
|
||||||
|
pickLocation.launch(null)
|
||||||
|
}
|
||||||
|
default // Don't update when non-default chosen
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberDefaultDownloadDir(): Pair<String, String> {
|
||||||
|
val appName = stringResource(id = R.string.app_name)
|
||||||
|
return remember {
|
||||||
|
val file = UniFile.fromFile(
|
||||||
|
File(
|
||||||
|
"${Environment.getExternalStorageDirectory().absolutePath}${File.separator}$appName",
|
||||||
|
"downloads",
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
file.uri.toString() to file.filePath!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDeleteChaptersGroup(
|
||||||
|
downloadPreferences: DownloadPreferences,
|
||||||
|
categories: List<Category>,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_delete_chapters),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadPreferences.removeAfterMarkedAsRead(),
|
||||||
|
title = stringResource(id = R.string.pref_remove_after_marked_as_read),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = downloadPreferences.removeAfterReadSlots(),
|
||||||
|
title = stringResource(id = R.string.pref_remove_after_read),
|
||||||
|
entries = mapOf(
|
||||||
|
-1 to stringResource(id = R.string.disabled),
|
||||||
|
0 to stringResource(id = R.string.last_read_chapter),
|
||||||
|
1 to stringResource(id = R.string.second_to_last),
|
||||||
|
2 to stringResource(id = R.string.third_to_last),
|
||||||
|
3 to stringResource(id = R.string.fourth_to_last),
|
||||||
|
4 to stringResource(id = R.string.fifth_to_last),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadPreferences.removeBookmarkedChapters(),
|
||||||
|
title = stringResource(id = R.string.pref_remove_bookmarked_chapters),
|
||||||
|
),
|
||||||
|
getExcludedCategoriesPreference(
|
||||||
|
downloadPreferences = downloadPreferences,
|
||||||
|
categories = { categories },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getExcludedCategoriesPreference(
|
||||||
|
downloadPreferences: DownloadPreferences,
|
||||||
|
categories: () -> List<Category>,
|
||||||
|
): Preference.PreferenceItem.MultiSelectListPreference {
|
||||||
|
val none = stringResource(id = R.string.none)
|
||||||
|
val pref = downloadPreferences.removeExcludeCategories()
|
||||||
|
val entries = categories().associate { it.id.toString() to it.visualName }
|
||||||
|
val subtitle by produceState(initialValue = "") {
|
||||||
|
pref.changes()
|
||||||
|
.stateIn(this)
|
||||||
|
.collect { mutable ->
|
||||||
|
value = mutable
|
||||||
|
.mapNotNull { id -> entries[id] }
|
||||||
|
.sortedBy { entries.values.indexOf(it) }
|
||||||
|
.joinToString()
|
||||||
|
.ifEmpty { none }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Preference.PreferenceItem.MultiSelectListPreference(
|
||||||
|
pref = pref,
|
||||||
|
title = stringResource(id = R.string.pref_remove_exclude_categories),
|
||||||
|
subtitle = subtitle,
|
||||||
|
entries = entries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDownloadNewChaptersGroup(
|
||||||
|
downloadPreferences: DownloadPreferences,
|
||||||
|
allCategories: List<Category>,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val downloadNewChaptersPref = downloadPreferences.downloadNewChapters()
|
||||||
|
val downloadNewChapterCategoriesPref = downloadPreferences.downloadNewChapterCategories()
|
||||||
|
val downloadNewChapterCategoriesExcludePref = downloadPreferences.downloadNewChapterCategoriesExclude()
|
||||||
|
|
||||||
|
val downloadNewChapters by downloadNewChaptersPref.collectAsState()
|
||||||
|
|
||||||
|
val included by downloadNewChapterCategoriesPref.collectAsState()
|
||||||
|
val excluded by downloadNewChapterCategoriesExcludePref.collectAsState()
|
||||||
|
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (showDialog) {
|
||||||
|
TriStateListDialog(
|
||||||
|
title = stringResource(id = R.string.categories),
|
||||||
|
message = stringResource(id = R.string.pref_download_new_categories_details),
|
||||||
|
items = allCategories,
|
||||||
|
initialChecked = included.mapNotNull { id -> allCategories.find { it.id.toString() == id } },
|
||||||
|
initialInversed = excluded.mapNotNull { id -> allCategories.find { it.id.toString() == id } },
|
||||||
|
itemLabel = { it.visualName },
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onValueChanged = { newIncluded, newExcluded ->
|
||||||
|
downloadNewChapterCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||||
|
downloadNewChapterCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet())
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_download_new),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = downloadNewChaptersPref,
|
||||||
|
title = stringResource(id = R.string.pref_download_new),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.categories),
|
||||||
|
subtitle = getCategoriesLabel(
|
||||||
|
allCategories = allCategories,
|
||||||
|
included = included,
|
||||||
|
excluded = excluded,
|
||||||
|
),
|
||||||
|
onClick = { showDialog = true },
|
||||||
|
enabled = downloadNewChapters,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDownloadAheadGroup(
|
||||||
|
downloadPreferences: DownloadPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.download_ahead),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = downloadPreferences.autoDownloadWhileReading(),
|
||||||
|
title = stringResource(id = R.string.auto_download_while_reading),
|
||||||
|
entries = listOf(0, 2, 3, 5, 10).associateWith {
|
||||||
|
if (it == 0) {
|
||||||
|
stringResource(id = R.string.disabled)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(id = R.plurals.next_unread_chapters, count = it, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.download_ahead_info)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
|
import eu.kanade.domain.library.service.LibraryPreferences
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsGeneralScreen : SearchableSettings {
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_general)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val prefs = remember { Injekt.get<BasePreferences>() }
|
||||||
|
val libraryPrefs = remember { Injekt.get<LibraryPreferences>() }
|
||||||
|
return mutableListOf<Preference>().apply {
|
||||||
|
add(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = libraryPrefs.showUpdatesNavBadge(),
|
||||||
|
title = stringResource(id = R.string.pref_library_update_show_tab_badge),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = prefs.confirmExit(),
|
||||||
|
title = stringResource(id = R.string.pref_confirm_exit),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
add(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.pref_manage_notifications),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val langs = remember { getLangs(context) }
|
||||||
|
val currentLanguage = remember { AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "" }
|
||||||
|
add(
|
||||||
|
Preference.PreferenceItem.BasicListPreference(
|
||||||
|
value = currentLanguage,
|
||||||
|
title = stringResource(id = R.string.pref_app_language),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = langs,
|
||||||
|
onValueChanged = { newValue ->
|
||||||
|
val locale = if (newValue.isEmpty()) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(newValue)
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(locale)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLangs(context: Context): Map<String, String> {
|
||||||
|
val langs = mutableListOf<Pair<String, String>>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
var eventType = parser.eventType
|
||||||
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (eventType == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
for (i in 0 until parser.attributeCount) {
|
||||||
|
if (parser.getAttributeName(i) == "name") {
|
||||||
|
val langTag = parser.getAttributeValue(i)
|
||||||
|
val displayName = LocaleHelper.getDisplayName(langTag)
|
||||||
|
if (displayName.isNotEmpty()) {
|
||||||
|
langs.add(Pair(langTag, displayName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventType = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
langs.sortBy { it.second }
|
||||||
|
langs.add(0, Pair("", context.getString(R.string.label_default)))
|
||||||
|
|
||||||
|
return langs.toMap()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,360 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import com.bluelinelabs.conductor.Router
|
||||||
|
import com.chargemap.compose.numberpicker.NumberPicker
|
||||||
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
|
import eu.kanade.domain.category.interactor.ResetCategoryFlags
|
||||||
|
import eu.kanade.domain.category.model.Category
|
||||||
|
import eu.kanade.domain.library.service.LibraryPreferences
|
||||||
|
import eu.kanade.presentation.category.visualName
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.more.settings.widget.TriStateListDialog
|
||||||
|
import eu.kanade.presentation.util.LocalRouter
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW
|
||||||
|
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
|
||||||
|
import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED
|
||||||
|
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
|
||||||
|
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
|
||||||
|
import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
|
||||||
|
import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||||
|
import eu.kanade.tachiyomi.ui.category.CategoryController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsLibraryScreen : SearchableSettings {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_library)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val getCategories = remember { Injekt.get<GetCategories>() }
|
||||||
|
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
|
||||||
|
val allCategories by getCategories.subscribe().collectAsState(initial = runBlocking { getCategories.await() })
|
||||||
|
|
||||||
|
return mutableListOf(
|
||||||
|
getDisplayGroup(libraryPreferences),
|
||||||
|
getCategoriesGroup(LocalRouter.currentOrThrow, allCategories, libraryPreferences),
|
||||||
|
getGlobalUpdateGroup(allCategories, libraryPreferences),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDisplayGroup(libraryPreferences: LibraryPreferences): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val portraitColumns by libraryPreferences.portraitColumns().stateIn(scope).collectAsState()
|
||||||
|
val landscapeColumns by libraryPreferences.landscapeColumns().stateIn(scope).collectAsState()
|
||||||
|
|
||||||
|
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (showDialog) {
|
||||||
|
LibraryColumnsDialog(
|
||||||
|
initialPortrait = portraitColumns,
|
||||||
|
initialLandscape = landscapeColumns,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onValueChanged = { portrait, landscape ->
|
||||||
|
libraryPreferences.portraitColumns().set(portrait)
|
||||||
|
libraryPreferences.landscapeColumns().set(landscape)
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(R.string.pref_category_display),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_library_columns),
|
||||||
|
subtitle = "${stringResource(R.string.portrait)}: ${getColumnValue(context, portraitColumns)}, " +
|
||||||
|
"${stringResource(R.string.landscape)}: ${getColumnValue(context, landscapeColumns)}",
|
||||||
|
onClick = { showDialog = true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getCategoriesGroup(
|
||||||
|
router: Router?,
|
||||||
|
allCategories: List<Category>,
|
||||||
|
libraryPreferences: LibraryPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val userCategoriesCount = allCategories.filterNot(Category::isSystemCategory).size
|
||||||
|
|
||||||
|
val defaultCategory by libraryPreferences.defaultCategory().collectAsState()
|
||||||
|
val selectedCategory = allCategories.find { it.id == defaultCategory.toLong() }
|
||||||
|
|
||||||
|
// For default category
|
||||||
|
val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) +
|
||||||
|
allCategories.map { it.id.toInt() }
|
||||||
|
val labels = listOf(stringResource(id = R.string.default_category_summary)) +
|
||||||
|
allCategories.map { it.visualName(context) }
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.categories),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.action_edit_categories),
|
||||||
|
subtitle = pluralStringResource(
|
||||||
|
id = R.plurals.num_categories,
|
||||||
|
count = userCategoriesCount,
|
||||||
|
userCategoriesCount,
|
||||||
|
),
|
||||||
|
onClick = { router?.pushController(CategoryController()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = libraryPreferences.defaultCategory(),
|
||||||
|
title = stringResource(id = R.string.default_category),
|
||||||
|
subtitle = selectedCategory?.visualName ?: stringResource(id = R.string.default_category_summary),
|
||||||
|
entries = ids.zip(labels).toMap(),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = libraryPreferences.categorizedDisplaySettings(),
|
||||||
|
title = stringResource(id = R.string.categorized_display_settings),
|
||||||
|
onValueChanged = {
|
||||||
|
if (!it) {
|
||||||
|
scope.launch {
|
||||||
|
Injekt.get<ResetCategoryFlags>().await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getGlobalUpdateGroup(
|
||||||
|
allCategories: List<Category>,
|
||||||
|
libraryPreferences: LibraryPreferences,
|
||||||
|
): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval()
|
||||||
|
val libraryUpdateDeviceRestrictionPref = libraryPreferences.libraryUpdateDeviceRestriction()
|
||||||
|
val libraryUpdateMangaRestrictionPref = libraryPreferences.libraryUpdateMangaRestriction()
|
||||||
|
val libraryUpdateCategoriesPref = libraryPreferences.libraryUpdateCategories()
|
||||||
|
val libraryUpdateCategoriesExcludePref = libraryPreferences.libraryUpdateCategoriesExclude()
|
||||||
|
|
||||||
|
val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState()
|
||||||
|
|
||||||
|
val deviceRestrictionEntries = mapOf(
|
||||||
|
DEVICE_ONLY_ON_WIFI to stringResource(id = R.string.connected_to_wifi),
|
||||||
|
DEVICE_NETWORK_NOT_METERED to stringResource(id = R.string.network_not_metered),
|
||||||
|
DEVICE_CHARGING to stringResource(id = R.string.charging),
|
||||||
|
DEVICE_BATTERY_NOT_LOW to stringResource(id = R.string.battery_not_low),
|
||||||
|
)
|
||||||
|
val deviceRestrictions = libraryUpdateDeviceRestrictionPref.collectAsState()
|
||||||
|
.value
|
||||||
|
.sorted()
|
||||||
|
.map { deviceRestrictionEntries.getOrElse(it) { it } }
|
||||||
|
.let { if (it.isEmpty()) stringResource(id = R.string.none) else it.joinToString() }
|
||||||
|
|
||||||
|
val mangaRestrictionEntries = mapOf(
|
||||||
|
MANGA_HAS_UNREAD to stringResource(id = R.string.pref_update_only_completely_read),
|
||||||
|
MANGA_NON_READ to stringResource(id = R.string.pref_update_only_started),
|
||||||
|
MANGA_NON_COMPLETED to stringResource(id = R.string.pref_update_only_non_completed),
|
||||||
|
)
|
||||||
|
val mangaRestrictions = libraryUpdateMangaRestrictionPref.collectAsState()
|
||||||
|
.value
|
||||||
|
.map { mangaRestrictionEntries.getOrElse(it) { it } }
|
||||||
|
.let { if (it.isEmpty()) stringResource(id = R.string.none) else it.joinToString() }
|
||||||
|
|
||||||
|
val included by libraryUpdateCategoriesPref.collectAsState()
|
||||||
|
val excluded by libraryUpdateCategoriesExcludePref.collectAsState()
|
||||||
|
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (showDialog) {
|
||||||
|
TriStateListDialog(
|
||||||
|
title = stringResource(id = R.string.categories),
|
||||||
|
message = stringResource(id = R.string.pref_library_update_categories_details),
|
||||||
|
items = allCategories,
|
||||||
|
initialChecked = included.mapNotNull { id -> allCategories.find { it.id.toString() == id } },
|
||||||
|
initialInversed = excluded.mapNotNull { id -> allCategories.find { it.id.toString() == id } },
|
||||||
|
itemLabel = { it.visualName },
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onValueChanged = { newIncluded, newExcluded ->
|
||||||
|
libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||||
|
libraryUpdateCategoriesExcludePref.set(newExcluded.map { it.id.toString() }.toSet())
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_library_update),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = libraryUpdateIntervalPref,
|
||||||
|
title = stringResource(id = R.string.pref_library_update_interval),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = mapOf(
|
||||||
|
0 to stringResource(id = R.string.update_never),
|
||||||
|
12 to stringResource(id = R.string.update_12hour),
|
||||||
|
24 to stringResource(id = R.string.update_24hour),
|
||||||
|
48 to stringResource(id = R.string.update_48hour),
|
||||||
|
72 to stringResource(id = R.string.update_72hour),
|
||||||
|
168 to stringResource(id = R.string.update_weekly),
|
||||||
|
),
|
||||||
|
onValueChanged = {
|
||||||
|
LibraryUpdateJob.setupTask(context, it)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.MultiSelectListPreference(
|
||||||
|
pref = libraryUpdateDeviceRestrictionPref,
|
||||||
|
enabled = libraryUpdateInterval > 0,
|
||||||
|
title = stringResource(id = R.string.pref_library_update_restriction),
|
||||||
|
subtitle = stringResource(id = R.string.restrictions, deviceRestrictions),
|
||||||
|
entries = deviceRestrictionEntries,
|
||||||
|
onValueChanged = {
|
||||||
|
// Post to event looper to allow the preference to be updated.
|
||||||
|
ContextCompat.getMainExecutor(context).execute { LibraryUpdateJob.setupTask(context) }
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.MultiSelectListPreference(
|
||||||
|
pref = libraryUpdateMangaRestrictionPref,
|
||||||
|
title = stringResource(id = R.string.pref_library_update_manga_restriction),
|
||||||
|
subtitle = mangaRestrictions,
|
||||||
|
entries = mangaRestrictionEntries,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(id = R.string.categories),
|
||||||
|
subtitle = getCategoriesLabel(
|
||||||
|
allCategories = allCategories,
|
||||||
|
included = included,
|
||||||
|
excluded = excluded,
|
||||||
|
),
|
||||||
|
onClick = { showDialog = true },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = libraryPreferences.autoUpdateMetadata(),
|
||||||
|
title = stringResource(id = R.string.pref_library_update_refresh_metadata),
|
||||||
|
subtitle = stringResource(id = R.string.pref_library_update_refresh_metadata_summary),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = libraryPreferences.autoUpdateTrackers(),
|
||||||
|
enabled = Injekt.get<TrackManager>().hasLoggedServices(),
|
||||||
|
title = stringResource(id = R.string.pref_library_update_refresh_trackers),
|
||||||
|
subtitle = stringResource(id = R.string.pref_library_update_refresh_trackers_summary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryColumnsDialog(
|
||||||
|
initialPortrait: Int,
|
||||||
|
initialLandscape: Int,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onValueChanged: (portrait: Int, landscape: Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var portraitValue by rememberSaveable { mutableStateOf(initialPortrait) }
|
||||||
|
var landscapeValue by rememberSaveable { mutableStateOf(initialLandscape) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = stringResource(id = R.string.pref_library_columns)) },
|
||||||
|
text = {
|
||||||
|
Row {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.portrait),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
NumberPicker(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds(),
|
||||||
|
value = portraitValue,
|
||||||
|
onValueChange = { portraitValue = it },
|
||||||
|
range = 0..10,
|
||||||
|
label = { getColumnValue(context, it) },
|
||||||
|
dividersColor = MaterialTheme.colorScheme.primary,
|
||||||
|
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.landscape),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
NumberPicker(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds(),
|
||||||
|
value = landscapeValue,
|
||||||
|
onValueChange = { landscapeValue = it },
|
||||||
|
range = 0..10,
|
||||||
|
label = { getColumnValue(context, it) },
|
||||||
|
dividersColor = MaterialTheme.colorScheme.primary,
|
||||||
|
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onValueChanged(portraitValue, landscapeValue) }) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getColumnValue(context: Context, value: Int): String {
|
||||||
|
return if (value == 0) {
|
||||||
|
context.getString(R.string.label_default)
|
||||||
|
} else {
|
||||||
|
value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ChromeReaderMode
|
||||||
|
import androidx.compose.material.icons.outlined.Code
|
||||||
|
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||||
|
import androidx.compose.material.icons.outlined.Explore
|
||||||
|
import androidx.compose.material.icons.outlined.GetApp
|
||||||
|
import androidx.compose.material.icons.outlined.Palette
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material.icons.outlined.Security
|
||||||
|
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
||||||
|
import androidx.compose.material.icons.outlined.Sync
|
||||||
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.presentation.components.AppBar
|
||||||
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
||||||
|
import eu.kanade.presentation.util.LocalBackPress
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
object SettingsMainScreen : SearchableSettings {
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.label_settings)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_general),
|
||||||
|
icon = Icons.Outlined.Tune,
|
||||||
|
onClick = { navigator.push(SettingsGeneralScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_appearance),
|
||||||
|
icon = Icons.Outlined.Palette,
|
||||||
|
onClick = { navigator.push(SettingsAppearanceScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_library),
|
||||||
|
icon = Icons.Outlined.CollectionsBookmark,
|
||||||
|
onClick = { navigator.push(SettingsLibraryScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_reader),
|
||||||
|
icon = Icons.Outlined.ChromeReaderMode,
|
||||||
|
onClick = { navigator.push(SettingsReaderScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_downloads),
|
||||||
|
icon = Icons.Outlined.GetApp,
|
||||||
|
onClick = { navigator.push(SettingsDownloadScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_tracking),
|
||||||
|
icon = Icons.Outlined.Sync,
|
||||||
|
onClick = { navigator.push(SettingsTrackingScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.browse),
|
||||||
|
icon = Icons.Outlined.Explore,
|
||||||
|
onClick = { navigator.push(SettingsBrowseScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.label_backup),
|
||||||
|
icon = Icons.Outlined.SettingsBackupRestore,
|
||||||
|
onClick = { navigator.push(SettingsBackupScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_security),
|
||||||
|
icon = Icons.Outlined.Security,
|
||||||
|
onClick = { navigator.push(SettingsSecurityScreen()) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(R.string.pref_category_advanced),
|
||||||
|
icon = Icons.Outlined.Code,
|
||||||
|
onClick = { navigator.push(SettingsAdvancedScreen()) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val backPress = LocalBackPress.currentOrThrow
|
||||||
|
PreferenceScaffold(
|
||||||
|
title = getTitle(),
|
||||||
|
actions = {
|
||||||
|
AppBarActions(
|
||||||
|
listOf(
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.action_search),
|
||||||
|
icon = Icons.Outlined.Search,
|
||||||
|
onClick = { navigator.push(SettingsSearchScreen()) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onBackPressed = backPress::invoke,
|
||||||
|
itemsProvider = { getPreferences() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,312 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues.ReaderHideThreshold
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsReaderScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_reader)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val readerPref = remember { Injekt.get<ReaderPreferences>() }
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPref.defaultReadingMode(),
|
||||||
|
title = stringResource(id = R.string.pref_viewer_type),
|
||||||
|
entries = ReadingModeType.values().drop(1)
|
||||||
|
.associate { it.flagValue to stringResource(id = it.stringRes) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPref.doubleTapAnimSpeed(),
|
||||||
|
title = stringResource(id = R.string.pref_double_tap_anim_speed),
|
||||||
|
entries = mapOf(
|
||||||
|
1 to stringResource(id = R.string.double_tap_anim_speed_0),
|
||||||
|
500 to stringResource(id = R.string.double_tap_anim_speed_normal),
|
||||||
|
250 to stringResource(id = R.string.double_tap_anim_speed_fast),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPref.showReadingMode(),
|
||||||
|
title = stringResource(id = R.string.pref_show_reading_mode),
|
||||||
|
subtitle = stringResource(id = R.string.pref_show_reading_mode_summary),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPref.showNavigationOverlayOnStart(),
|
||||||
|
title = stringResource(id = R.string.pref_show_navigation_mode),
|
||||||
|
subtitle = stringResource(id = R.string.pref_show_navigation_mode_summary),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPref.trueColor(),
|
||||||
|
title = stringResource(id = R.string.pref_true_color),
|
||||||
|
subtitle = stringResource(id = R.string.pref_true_color_summary),
|
||||||
|
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPref.pageTransitions(),
|
||||||
|
title = stringResource(id = R.string.pref_page_transitions),
|
||||||
|
),
|
||||||
|
getDisplayGroup(readerPreferences = readerPref),
|
||||||
|
getPagedGroup(readerPreferences = readerPref),
|
||||||
|
getWebtoonGroup(readerPreferences = readerPref),
|
||||||
|
getNavigationGroup(readerPreferences = readerPref),
|
||||||
|
getActionsGroup(readerPreferences = readerPref),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
|
val fullscreenPref = readerPreferences.fullscreen()
|
||||||
|
val fullscreen by fullscreenPref.collectAsState()
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_category_display),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.defaultOrientationType(),
|
||||||
|
title = stringResource(id = R.string.pref_rotation_type),
|
||||||
|
entries = OrientationType.values().drop(1)
|
||||||
|
.associate { it.flagValue to stringResource(id = it.stringRes) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.readerTheme(),
|
||||||
|
title = stringResource(id = R.string.pref_reader_theme),
|
||||||
|
entries = mapOf(
|
||||||
|
1 to stringResource(id = R.string.black_background),
|
||||||
|
2 to stringResource(id = R.string.gray_background),
|
||||||
|
0 to stringResource(id = R.string.white_background),
|
||||||
|
3 to stringResource(id = R.string.automatic_background),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = fullscreenPref,
|
||||||
|
title = stringResource(id = R.string.pref_fullscreen),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.cutoutShort(),
|
||||||
|
title = stringResource(id = R.string.pref_cutout_short),
|
||||||
|
enabled = fullscreen &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||||
|
LocalView.current.rootWindowInsets?.displayCutout != null, // has cutout
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.keepScreenOn(),
|
||||||
|
title = stringResource(id = R.string.pref_keep_screen_on),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.showPageNumber(),
|
||||||
|
title = stringResource(id = R.string.pref_show_page_number),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getPagedGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
|
val navModePref = readerPreferences.navigationModePager()
|
||||||
|
val imageScaleTypePref = readerPreferences.imageScaleType()
|
||||||
|
val dualPageSplitPref = readerPreferences.dualPageSplitPaged()
|
||||||
|
|
||||||
|
val navMode by navModePref.collectAsState()
|
||||||
|
val imageScaleType by imageScaleTypePref.collectAsState()
|
||||||
|
val dualPageSplit by dualPageSplitPref.collectAsState()
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pager_viewer),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = navModePref,
|
||||||
|
title = stringResource(id = R.string.pref_viewer_nav),
|
||||||
|
entries = stringArrayResource(id = R.array.pager_nav).let {
|
||||||
|
it.indices.zip(it).toMap()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.pagerNavInverted(),
|
||||||
|
title = stringResource(id = R.string.pref_read_with_tapping_inverted),
|
||||||
|
entries = mapOf(
|
||||||
|
TappingInvertMode.NONE to stringResource(id = R.string.none),
|
||||||
|
TappingInvertMode.HORIZONTAL to stringResource(id = R.string.tapping_inverted_horizontal),
|
||||||
|
TappingInvertMode.VERTICAL to stringResource(id = R.string.tapping_inverted_vertical),
|
||||||
|
TappingInvertMode.BOTH to stringResource(id = R.string.tapping_inverted_both),
|
||||||
|
),
|
||||||
|
enabled = navMode != 5,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.navigateToPan(),
|
||||||
|
title = stringResource(id = R.string.pref_navigate_pan),
|
||||||
|
enabled = navMode != 5,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = imageScaleTypePref,
|
||||||
|
title = stringResource(id = R.string.pref_image_scale_type),
|
||||||
|
entries = mapOf(
|
||||||
|
1 to stringResource(id = R.string.scale_type_fit_screen),
|
||||||
|
2 to stringResource(id = R.string.scale_type_stretch),
|
||||||
|
3 to stringResource(id = R.string.scale_type_fit_width),
|
||||||
|
4 to stringResource(id = R.string.scale_type_fit_height),
|
||||||
|
5 to stringResource(id = R.string.scale_type_original_size),
|
||||||
|
6 to stringResource(id = R.string.scale_type_smart_fit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.landscapeZoom(),
|
||||||
|
title = stringResource(id = R.string.pref_landscape_zoom),
|
||||||
|
enabled = imageScaleType == 1,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.zoomStart(),
|
||||||
|
title = stringResource(id = R.string.pref_zoom_start),
|
||||||
|
entries = mapOf(
|
||||||
|
1 to stringResource(id = R.string.zoom_start_automatic),
|
||||||
|
2 to stringResource(id = R.string.zoom_start_left),
|
||||||
|
3 to stringResource(id = R.string.zoom_start_right),
|
||||||
|
4 to stringResource(id = R.string.zoom_start_center),
|
||||||
|
),
|
||||||
|
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.cropBorders(),
|
||||||
|
title = stringResource(id = R.string.pref_crop_borders),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = dualPageSplitPref,
|
||||||
|
title = stringResource(id = R.string.pref_dual_page_split),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.dualPageInvertPaged(),
|
||||||
|
title = stringResource(id = R.string.pref_dual_page_invert),
|
||||||
|
subtitle = stringResource(id = R.string.pref_dual_page_invert_summary),
|
||||||
|
enabled = dualPageSplit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getWebtoonGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
|
val navModePref = readerPreferences.navigationModeWebtoon()
|
||||||
|
val dualPageSplitPref = readerPreferences.dualPageSplitWebtoon()
|
||||||
|
|
||||||
|
val navMode by navModePref.collectAsState()
|
||||||
|
val dualPageSplit by dualPageSplitPref.collectAsState()
|
||||||
|
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.webtoon_viewer),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = navModePref,
|
||||||
|
title = stringResource(id = R.string.pref_viewer_nav),
|
||||||
|
entries = stringArrayResource(id = R.array.webtoon_nav).let {
|
||||||
|
it.indices.zip(it).toMap()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.webtoonNavInverted(),
|
||||||
|
title = stringResource(id = R.string.pref_read_with_tapping_inverted),
|
||||||
|
entries = mapOf(
|
||||||
|
TappingInvertMode.NONE to stringResource(id = R.string.none),
|
||||||
|
TappingInvertMode.HORIZONTAL to stringResource(id = R.string.tapping_inverted_horizontal),
|
||||||
|
TappingInvertMode.VERTICAL to stringResource(id = R.string.tapping_inverted_vertical),
|
||||||
|
TappingInvertMode.BOTH to stringResource(id = R.string.tapping_inverted_both),
|
||||||
|
),
|
||||||
|
enabled = navMode != 5,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.webtoonSidePadding(),
|
||||||
|
title = stringResource(id = R.string.pref_webtoon_side_padding),
|
||||||
|
entries = mapOf(
|
||||||
|
0 to stringResource(id = R.string.webtoon_side_padding_0),
|
||||||
|
5 to stringResource(id = R.string.webtoon_side_padding_5),
|
||||||
|
10 to stringResource(id = R.string.webtoon_side_padding_10),
|
||||||
|
15 to stringResource(id = R.string.webtoon_side_padding_15),
|
||||||
|
20 to stringResource(id = R.string.webtoon_side_padding_20),
|
||||||
|
25 to stringResource(id = R.string.webtoon_side_padding_25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = readerPreferences.readerHideThreshold(),
|
||||||
|
title = stringResource(id = R.string.pref_hide_threshold),
|
||||||
|
entries = mapOf(
|
||||||
|
ReaderHideThreshold.HIGHEST to stringResource(id = R.string.pref_highest),
|
||||||
|
ReaderHideThreshold.HIGH to stringResource(id = R.string.pref_high),
|
||||||
|
ReaderHideThreshold.LOW to stringResource(id = R.string.pref_low),
|
||||||
|
ReaderHideThreshold.LOWEST to stringResource(id = R.string.pref_lowest),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.cropBordersWebtoon(),
|
||||||
|
title = stringResource(id = R.string.pref_crop_borders),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = dualPageSplitPref,
|
||||||
|
title = stringResource(id = R.string.pref_dual_page_split),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.dualPageInvertWebtoon(),
|
||||||
|
title = stringResource(id = R.string.pref_dual_page_invert),
|
||||||
|
subtitle = stringResource(id = R.string.pref_dual_page_invert_summary),
|
||||||
|
enabled = dualPageSplit,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.longStripSplitWebtoon(),
|
||||||
|
title = stringResource(id = R.string.pref_long_strip_split),
|
||||||
|
subtitle = stringResource(id = R.string.split_tall_images_summary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getNavigationGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
|
val readWithVolumeKeysPref = readerPreferences.readWithVolumeKeys()
|
||||||
|
val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState()
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_reader_navigation),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readWithVolumeKeysPref,
|
||||||
|
title = stringResource(id = R.string.pref_read_with_volume_keys),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.readWithVolumeKeysInverted(),
|
||||||
|
title = stringResource(id = R.string.pref_read_with_volume_keys_inverted),
|
||||||
|
enabled = readWithVolumeKeys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getActionsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
|
return Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.pref_reader_actions),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.readWithLongTap(),
|
||||||
|
title = stringResource(id = R.string.pref_read_with_long_tap),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = readerPreferences.folderPerManga(),
|
||||||
|
title = stringResource(id = R.string.pref_create_folder_per_manga),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,303 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.paddingFromBaseline
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.presentation.components.Divider
|
||||||
|
import eu.kanade.presentation.components.EmptyScreen
|
||||||
|
import eu.kanade.presentation.components.Scaffold
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.util.system.isLTR
|
||||||
|
|
||||||
|
class SettingsSearchScreen : Screen {
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val softKeyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Hide keyboard on change screen
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
softKeyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide keyboard on outside text field is touched
|
||||||
|
LaunchedEffect(listState.isScrollInProgress) {
|
||||||
|
if (listState.isScrollInProgress) {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request text field focus on launch
|
||||||
|
LaunchedEffect(focusRequester) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = navigator::pop) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
BasicTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { textFieldValue = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge
|
||||||
|
.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
decorationBox = {
|
||||||
|
if (textFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.action_search_settings),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
it()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (textFieldValue.text.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { textFieldValue = TextFieldValue() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
SearchResult(
|
||||||
|
searchKey = textFieldValue.text,
|
||||||
|
listState = listState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
) { result ->
|
||||||
|
SearchableSettings.highlightKey = result.highlightKey
|
||||||
|
navigator.popUntil { it is SettingsMainScreen }
|
||||||
|
navigator.push(result.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResult(
|
||||||
|
searchKey: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
onItemClick: (SearchResultItem) -> Unit,
|
||||||
|
) {
|
||||||
|
if (searchKey.isEmpty()) return
|
||||||
|
|
||||||
|
val index = getIndex()
|
||||||
|
val result by produceState<List<SearchResultItem>?>(initialValue = null, searchKey) {
|
||||||
|
value = index.asSequence()
|
||||||
|
.flatMap { settingsData ->
|
||||||
|
settingsData.contents.asSequence()
|
||||||
|
// Only search from enabled prefs and one with valid title
|
||||||
|
.filter { it.enabled && it.title.isNotBlank() }
|
||||||
|
// Flatten items contained inside *enabled* PreferenceGroup
|
||||||
|
.flatMap { p ->
|
||||||
|
when (p) {
|
||||||
|
is Preference.PreferenceGroup -> {
|
||||||
|
if (p.enabled) {
|
||||||
|
p.preferenceItems.asSequence()
|
||||||
|
.filter { it.enabled && it.title.isNotBlank() }
|
||||||
|
.map { p.title to it }
|
||||||
|
} else {
|
||||||
|
emptySequence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem<*> -> sequenceOf(null to p)
|
||||||
|
else -> emptySequence() // Ignore other prefs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter by search query
|
||||||
|
.filter { (_, p) ->
|
||||||
|
val inTitle = p.title.contains(searchKey, true)
|
||||||
|
val inSummary = p.subtitle?.contains(searchKey, true) ?: false
|
||||||
|
inTitle || inSummary
|
||||||
|
}
|
||||||
|
// Map result data
|
||||||
|
.map { (categoryTitle, p) ->
|
||||||
|
SearchResultItem(
|
||||||
|
route = settingsData.route,
|
||||||
|
title = p.title,
|
||||||
|
breadcrumbs = getLocalizedBreadcrumb(path = settingsData.title, node = categoryTitle),
|
||||||
|
highlightKey = p.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.take(10) // Just take top 10 result for quicker result
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
Crossfade(targetState = result) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
it == null -> {
|
||||||
|
/* Don't show anything just yet */
|
||||||
|
}
|
||||||
|
// No result
|
||||||
|
it.isEmpty() -> item { EmptyScreen(stringResource(id = R.string.no_results_found)) }
|
||||||
|
// Show result list
|
||||||
|
else -> items(
|
||||||
|
items = it,
|
||||||
|
key = { i -> i.hashCode() },
|
||||||
|
) { item ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onItemClick(item) }
|
||||||
|
.padding(horizontal = 24.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.breadcrumbs,
|
||||||
|
modifier = Modifier.paddingFromBaseline(top = 16.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
private fun getIndex() = settingScreens
|
||||||
|
.map { screen ->
|
||||||
|
SettingsData(
|
||||||
|
title = screen.getTitle(),
|
||||||
|
route = screen,
|
||||||
|
contents = screen.getPreferences(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLocalizedBreadcrumb(path: String, node: String?): String {
|
||||||
|
return if (node == null) {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
if (Resources.getSystem().isLTR) {
|
||||||
|
// This locale reads left to right.
|
||||||
|
"$path > $node"
|
||||||
|
} else {
|
||||||
|
// This locale reads right to left.
|
||||||
|
"$node < $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val settingScreens = listOf(
|
||||||
|
SettingsGeneralScreen(),
|
||||||
|
SettingsAppearanceScreen(),
|
||||||
|
SettingsLibraryScreen(),
|
||||||
|
SettingsReaderScreen(),
|
||||||
|
SettingsDownloadScreen(),
|
||||||
|
SettingsTrackingScreen(),
|
||||||
|
SettingsBrowseScreen(),
|
||||||
|
SettingsBackupScreen(),
|
||||||
|
SettingsSecurityScreen(),
|
||||||
|
SettingsAdvancedScreen(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SettingsData(
|
||||||
|
val title: String,
|
||||||
|
val route: Screen,
|
||||||
|
val contents: List<Preference>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SearchResultItem(
|
||||||
|
val route: Screen,
|
||||||
|
val title: String,
|
||||||
|
val breadcrumbs: String,
|
||||||
|
val highlightKey: String,
|
||||||
|
)
|
@ -0,0 +1,89 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.presentation.util.collectAsState
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||||
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||||
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsSecurityScreen : SearchableSettings {
|
||||||
|
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_security)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val securityPreferences = remember { Injekt.get<SecurityPreferences>() }
|
||||||
|
val authSupported = remember { context.isAuthenticationSupported() }
|
||||||
|
|
||||||
|
val useAuthPref = securityPreferences.useAuthenticator()
|
||||||
|
|
||||||
|
val useAuth by useAuthPref.collectAsState()
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = useAuthPref,
|
||||||
|
title = stringResource(id = R.string.lock_with_biometrics),
|
||||||
|
enabled = authSupported,
|
||||||
|
onValueChanged = {
|
||||||
|
(context as FragmentActivity).authenticate(
|
||||||
|
title = context.getString(R.string.lock_with_biometrics),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = securityPreferences.lockAppAfter(),
|
||||||
|
title = stringResource(id = R.string.lock_when_idle),
|
||||||
|
subtitle = "%s",
|
||||||
|
enabled = authSupported && useAuth,
|
||||||
|
entries = LockAfterValues
|
||||||
|
.associateWith {
|
||||||
|
when (it) {
|
||||||
|
-1 -> stringResource(id = R.string.lock_never)
|
||||||
|
0 -> stringResource(id = R.string.lock_always)
|
||||||
|
else -> pluralStringResource(id = R.plurals.lock_after_mins, count = it, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onValueChanged = {
|
||||||
|
(context as FragmentActivity).authenticate(
|
||||||
|
title = context.getString(R.string.lock_when_idle),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = securityPreferences.hideNotificationContent(),
|
||||||
|
title = stringResource(id = R.string.hide_notification_content),
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
pref = securityPreferences.secureScreen(),
|
||||||
|
title = stringResource(id = R.string.secure_screen),
|
||||||
|
subtitle = "%s",
|
||||||
|
entries = SecurityPreferences.SecureScreenMode.values()
|
||||||
|
.associateWith { stringResource(id = it.titleResId) },
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.secure_screen_summary)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LockAfterValues = listOf(
|
||||||
|
0, // Always
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
-1, // Never
|
||||||
|
)
|
@ -0,0 +1,336 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.HelpOutline
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.domain.track.service.TrackPreferences
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
||||||
|
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
|
||||||
|
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
|
||||||
|
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class SettingsTrackingScreen : SearchableSettings {
|
||||||
|
@ReadOnlyComposable
|
||||||
|
@Composable
|
||||||
|
override fun getTitle(): String = stringResource(id = R.string.pref_category_tracking)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun RowScope.AppBarAction() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = { context.openInBrowser("https://tachiyomi.org/help/guides/tracking/") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.HelpOutline,
|
||||||
|
contentDescription = stringResource(id = R.string.tracking_guide),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun getPreferences(): List<Preference> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val trackPreferences = remember { Injekt.get<TrackPreferences>() }
|
||||||
|
val trackManager = remember { Injekt.get<TrackManager>() }
|
||||||
|
|
||||||
|
var dialog by remember { mutableStateOf<Any?>(null) }
|
||||||
|
dialog?.run {
|
||||||
|
when (this) {
|
||||||
|
is LoginDialog -> {
|
||||||
|
TrackingLoginDialog(
|
||||||
|
service = service,
|
||||||
|
uNameStringRes = uNameStringRes,
|
||||||
|
onDismissRequest = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is LogoutDialog -> {
|
||||||
|
TrackingLogoutDialog(
|
||||||
|
service = service,
|
||||||
|
onDismissRequest = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
pref = trackPreferences.autoUpdateTrack(),
|
||||||
|
title = stringResource(id = R.string.pref_auto_update_manga_sync),
|
||||||
|
),
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.services),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.myAnimeList.nameRes()),
|
||||||
|
service = trackManager.myAnimeList,
|
||||||
|
login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.myAnimeList) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.aniList.nameRes()),
|
||||||
|
service = trackManager.aniList,
|
||||||
|
login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.aniList) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.kitsu.nameRes()),
|
||||||
|
service = trackManager.kitsu,
|
||||||
|
login = { dialog = LoginDialog(trackManager.kitsu, R.string.email) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.kitsu) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.mangaUpdates.nameRes()),
|
||||||
|
service = trackManager.mangaUpdates,
|
||||||
|
login = { dialog = LoginDialog(trackManager.mangaUpdates, R.string.username) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.mangaUpdates) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.shikimori.nameRes()),
|
||||||
|
service = trackManager.shikimori,
|
||||||
|
login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.shikimori) },
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.bangumi.nameRes()),
|
||||||
|
service = trackManager.bangumi,
|
||||||
|
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
|
||||||
|
logout = { dialog = LogoutDialog(trackManager.bangumi) },
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.tracking_info)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Preference.PreferenceGroup(
|
||||||
|
title = stringResource(id = R.string.enhanced_services),
|
||||||
|
preferenceItems = listOf(
|
||||||
|
Preference.PreferenceItem.TrackingPreference(
|
||||||
|
title = stringResource(id = trackManager.komga.nameRes()),
|
||||||
|
service = trackManager.komga,
|
||||||
|
login = {
|
||||||
|
val sourceManager = Injekt.get<SourceManager>()
|
||||||
|
val acceptedSources = trackManager.komga.getAcceptedSources()
|
||||||
|
val hasValidSourceInstalled = sourceManager.getCatalogueSources()
|
||||||
|
.any { it::class.qualifiedName in acceptedSources }
|
||||||
|
|
||||||
|
if (hasValidSourceInstalled) {
|
||||||
|
trackManager.komga.loginNoop()
|
||||||
|
} else {
|
||||||
|
context.toast(R.string.tracker_komga_warning, Toast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout = trackManager.komga::logout,
|
||||||
|
),
|
||||||
|
Preference.infoPreference(stringResource(id = R.string.enhanced_tracking_info)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackingLoginDialog(
|
||||||
|
service: TrackService,
|
||||||
|
@StringRes uNameStringRes: Int,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) }
|
||||||
|
var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) }
|
||||||
|
var processing by remember { mutableStateOf(false) }
|
||||||
|
var inputError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = stringResource(id = R.string.login_title, stringResource(id = service.nameRes()))) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text(text = stringResource(id = uNameStringRes)) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
singleLine = true,
|
||||||
|
isError = inputError && username.text.isEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
var hidePassword by remember { mutableStateOf(true) }
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.password)) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { hidePassword = !hidePassword }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (hidePassword) {
|
||||||
|
Icons.Default.Visibility
|
||||||
|
} else {
|
||||||
|
Icons.Default.VisibilityOff
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
visualTransformation = if (hidePassword) {
|
||||||
|
PasswordVisualTransformation()
|
||||||
|
} else {
|
||||||
|
VisualTransformation.None
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
singleLine = true,
|
||||||
|
isError = inputError && password.text.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Column {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !processing,
|
||||||
|
onClick = {
|
||||||
|
if (username.text.isEmpty() || password.text.isEmpty()) {
|
||||||
|
inputError = true
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
scope.launchIO {
|
||||||
|
inputError = false
|
||||||
|
processing = true
|
||||||
|
val result = checkLogin(
|
||||||
|
context = context,
|
||||||
|
service = service,
|
||||||
|
username = username.text,
|
||||||
|
password = password.text,
|
||||||
|
)
|
||||||
|
if (result) onDismissRequest()
|
||||||
|
processing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val id = if (processing) R.string.loading else R.string.login
|
||||||
|
Text(text = stringResource(id = id))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onDismissRequest,
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkLogin(
|
||||||
|
context: Context,
|
||||||
|
service: TrackService,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): Boolean {
|
||||||
|
return try {
|
||||||
|
service.login(username, password)
|
||||||
|
withUIContext { context.toast(R.string.login_success) }
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
service.logout()
|
||||||
|
withUIContext { context.toast(e.message.toString()) }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackingLogoutDialog(
|
||||||
|
service: TrackService,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.logout_title, stringResource(id = service.nameRes())),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = onDismissRequest,
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = {
|
||||||
|
service.logout()
|
||||||
|
onDismissRequest()
|
||||||
|
context.toast(R.string.logout_success)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.logout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LoginDialog(
|
||||||
|
val service: TrackService,
|
||||||
|
@StringRes val uNameStringRes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class LogoutDialog(
|
||||||
|
val service: TrackService,
|
||||||
|
)
|
@ -0,0 +1,270 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.domain.ui.model.AppTheme
|
||||||
|
import eu.kanade.presentation.components.DIVIDER_ALPHA
|
||||||
|
import eu.kanade.presentation.components.MangaCover
|
||||||
|
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||||
|
import eu.kanade.presentation.util.secondaryItemAlpha
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AppThemePreferenceWidget(
|
||||||
|
title: String,
|
||||||
|
value: AppTheme,
|
||||||
|
amoled: Boolean,
|
||||||
|
onItemClick: (AppTheme) -> Unit,
|
||||||
|
) {
|
||||||
|
BasePreferenceWidget(
|
||||||
|
title = title,
|
||||||
|
subcomponent = {
|
||||||
|
AppThemesList(
|
||||||
|
currentTheme = value,
|
||||||
|
amoled = amoled,
|
||||||
|
onItemClick = onItemClick,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppThemesList(
|
||||||
|
currentTheme: AppTheme,
|
||||||
|
amoled: Boolean,
|
||||||
|
onItemClick: (AppTheme) -> Unit,
|
||||||
|
) {
|
||||||
|
val appThemes = remember {
|
||||||
|
AppTheme.values().filter { it.titleResId != null }
|
||||||
|
}
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.animateContentSize()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = HorizontalPadding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = appThemes,
|
||||||
|
key = { it.name },
|
||||||
|
) { appTheme ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(114.dp)
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
TachiyomiTheme(
|
||||||
|
appTheme = appTheme,
|
||||||
|
amoled = amoled,
|
||||||
|
) {
|
||||||
|
AppThemePreviewItem(
|
||||||
|
selected = currentTheme == appTheme,
|
||||||
|
onClick = { onItemClick(appTheme) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = appTheme.titleResId!!),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.secondaryItemAlpha(),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppThemePreviewItem(
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(9f / 16f)
|
||||||
|
.border(
|
||||||
|
width = 4.dp,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
dividerColor
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(17.dp),
|
||||||
|
)
|
||||||
|
.padding(4.dp)
|
||||||
|
.clip(RoundedCornerShape(13.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
// App Bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight(0.8f)
|
||||||
|
.weight(0.7f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shape = RoundedCornerShape(9.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(0.3f),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp, top = 2.dp)
|
||||||
|
.background(
|
||||||
|
color = dividerColor,
|
||||||
|
shape = RoundedCornerShape(9.dp),
|
||||||
|
)
|
||||||
|
.fillMaxWidth(0.5f)
|
||||||
|
.aspectRatio(MangaCover.Book.ratio),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(width = 24.dp, height = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(12.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.tertiary),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(12.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.secondary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom bar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(17.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.alpha(0.6f)
|
||||||
|
.height(17.dp)
|
||||||
|
.weight(1f)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shape = RoundedCornerShape(9.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(
|
||||||
|
name = "light",
|
||||||
|
showBackground = true,
|
||||||
|
)
|
||||||
|
@Preview(
|
||||||
|
name = "dark",
|
||||||
|
showBackground = true,
|
||||||
|
uiMode = UI_MODE_NIGHT_YES,
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
private fun AppThemesListPreview() {
|
||||||
|
var appTheme by remember { mutableStateOf(AppTheme.DEFAULT) }
|
||||||
|
TachiyomiTheme {
|
||||||
|
AppThemesList(
|
||||||
|
currentTheme = appTheme,
|
||||||
|
amoled = false,
|
||||||
|
onItemClick = { appTheme = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,176 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.StartOffset
|
||||||
|
import androidx.compose.animation.core.StartOffsetType
|
||||||
|
import androidx.compose.animation.core.repeatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted
|
||||||
|
import eu.kanade.presentation.util.secondaryItemAlpha
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BasePreferenceWidget(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
widget: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
BasePreferenceWidget(
|
||||||
|
modifier = modifier,
|
||||||
|
title = title,
|
||||||
|
subcomponent = if (!subtitle.isNullOrBlank()) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
start = HorizontalPadding,
|
||||||
|
top = 4.dp,
|
||||||
|
end = HorizontalPadding,
|
||||||
|
)
|
||||||
|
.secondaryItemAlpha(),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
icon = icon,
|
||||||
|
onClick = onClick,
|
||||||
|
widget = widget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BasePreferenceWidget(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String,
|
||||||
|
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
widget: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
BasePreferenceWidgetImpl(modifier, title, subcomponent, icon, onClick, widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BasePreferenceWidgetImpl(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String,
|
||||||
|
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
widget: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val highlighted = LocalPreferenceHighlighted.current
|
||||||
|
Box(modifier = Modifier.highlightBackground(highlighted)) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.sizeIn(minHeight = 56.dp)
|
||||||
|
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = HorizontalPadding, end = 12.dp)
|
||||||
|
.secondaryItemAlpha(),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = HorizontalPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 2,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subcomponent?.invoke(this)
|
||||||
|
}
|
||||||
|
if (widget != null) {
|
||||||
|
Box(modifier = Modifier.padding(end = HorizontalPadding)) {
|
||||||
|
widget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed {
|
||||||
|
var highlightFlag by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (highlighted) {
|
||||||
|
highlightFlag = true
|
||||||
|
delay(3000)
|
||||||
|
highlightFlag = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val highlight by animateColorAsState(
|
||||||
|
targetValue = if (highlightFlag) {
|
||||||
|
MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f)
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
},
|
||||||
|
animationSpec = if (highlightFlag) {
|
||||||
|
repeatable(
|
||||||
|
iterations = 5,
|
||||||
|
animation = tween(durationMillis = 200),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
initialStartOffset = StartOffset(
|
||||||
|
offsetMillis = 600,
|
||||||
|
offsetType = StartOffsetType.Delay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tween(200)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
then(Modifier.background(color = highlight))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val TrailingWidgetBuffer = 16.dp
|
||||||
|
internal val HorizontalPadding = 16.dp
|
@ -0,0 +1,79 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditTextPreferenceWidget(
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
icon: ImageVector?,
|
||||||
|
value: String,
|
||||||
|
onConfirm: suspend (String) -> Boolean,
|
||||||
|
) {
|
||||||
|
val (isDialogShown, showDialog) = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle?.format(value),
|
||||||
|
icon = icon,
|
||||||
|
onPreferenceClick = { showDialog(true) },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDialogShown) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onDismissRequest = { showDialog(false) }
|
||||||
|
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
|
mutableStateOf(TextFieldValue(value))
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { textFieldValue = it },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = true,
|
||||||
|
),
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (onConfirm(textFieldValue.text)) {
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.presentation.components.Divider
|
||||||
|
import eu.kanade.presentation.components.ScrollbarLazyColumn
|
||||||
|
import eu.kanade.presentation.util.isScrolledToEnd
|
||||||
|
import eu.kanade.presentation.util.isScrolledToStart
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> ListPreferenceWidget(
|
||||||
|
value: T,
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
icon: ImageVector?,
|
||||||
|
entries: Map<out T, String>,
|
||||||
|
onValueChange: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val (isDialogShown, showDialog) = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle?.format(entries[value]),
|
||||||
|
icon = icon,
|
||||||
|
onPreferenceClick = { showDialog(true) },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDialogShown) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDialog(false) },
|
||||||
|
title = { Text(text = title) },
|
||||||
|
text = {
|
||||||
|
Box {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
ScrollbarLazyColumn(state = state) {
|
||||||
|
entries.forEach { current ->
|
||||||
|
val isSelected = value == current.key
|
||||||
|
item {
|
||||||
|
DialogRow(
|
||||||
|
label = current.value,
|
||||||
|
isSelected = isSelected,
|
||||||
|
onSelected = {
|
||||||
|
onValueChange(current.key!!)
|
||||||
|
showDialog(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.isScrolledToStart()) Divider(modifier = Modifier.align(Alignment.TopCenter))
|
||||||
|
if (!state.isScrolledToEnd()) Divider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showDialog(false) }) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DialogRow(
|
||||||
|
label: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onSelected: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { if (!isSelected) onSelected() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { if (!isSelected) onSelected() },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.merge(),
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MultiSelectListPreferenceWidget(
|
||||||
|
preference: Preference.PreferenceItem.MultiSelectListPreference,
|
||||||
|
values: Set<String>,
|
||||||
|
onValuesChange: (Set<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
val (isDialogShown, showDialog) = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = preference.title,
|
||||||
|
subtitle = preference.subtitle,
|
||||||
|
icon = preference.icon,
|
||||||
|
onPreferenceClick = { showDialog(true) },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDialogShown) {
|
||||||
|
val selected = remember {
|
||||||
|
preference.entries.keys
|
||||||
|
.filter { values.contains(it) }
|
||||||
|
.toMutableStateList()
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDialog(false) },
|
||||||
|
title = { Text(text = preference.title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn {
|
||||||
|
preference.entries.forEach { current ->
|
||||||
|
item {
|
||||||
|
val isSelected = selected.contains(current.key)
|
||||||
|
val onSelectionChanged = {
|
||||||
|
when (!isSelected) {
|
||||||
|
true -> selected.add(current.key)
|
||||||
|
false -> selected.remove(current.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectionChanged() },
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { onSelectionChanged() },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = current.value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = true,
|
||||||
|
),
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onValuesChange(selected.toMutableSet())
|
||||||
|
showDialog(false)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDialog(false) }) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceGroupHeader(title: String) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp, top = 14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Preview
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwitchPreferenceWidget(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
checked: Boolean = false,
|
||||||
|
onCheckedChanged: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
BasePreferenceWidget(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
icon = icon,
|
||||||
|
onClick = { onCheckedChanged(!checked) },
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = null,
|
||||||
|
modifier = Modifier.padding(start = TrailingWidgetBuffer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun SwitchPreferenceWidgetPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface {
|
||||||
|
Column {
|
||||||
|
SwitchPreferenceWidget(
|
||||||
|
title = "Text preference with icon",
|
||||||
|
subtitle = "Text preference summary",
|
||||||
|
icon = Icons.Default.Preview,
|
||||||
|
checked = true,
|
||||||
|
onCheckedChanged = {},
|
||||||
|
)
|
||||||
|
SwitchPreferenceWidget(
|
||||||
|
title = "Text preference",
|
||||||
|
subtitle = "Text preference summary",
|
||||||
|
checked = false,
|
||||||
|
onCheckedChanged = {},
|
||||||
|
)
|
||||||
|
SwitchPreferenceWidget(
|
||||||
|
title = "Text preference no summary",
|
||||||
|
checked = false,
|
||||||
|
onCheckedChanged = {},
|
||||||
|
)
|
||||||
|
SwitchPreferenceWidget(
|
||||||
|
title = "Another text preference no summary",
|
||||||
|
checked = false,
|
||||||
|
onCheckedChanged = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Preview
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextPreferenceWidget(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onPreferenceClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
// TODO: Handle auth requirement here?
|
||||||
|
BasePreferenceWidget(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
icon = icon,
|
||||||
|
onClick = onPreferenceClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun TextPreferenceWidgetPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface {
|
||||||
|
Column {
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = "Text preference with icon",
|
||||||
|
subtitle = "Text preference summary",
|
||||||
|
icon = Icons.Default.Preview,
|
||||||
|
onPreferenceClick = {},
|
||||||
|
)
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = "Text preference",
|
||||||
|
subtitle = "Text preference summary",
|
||||||
|
onPreferenceClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TrackingPreferenceWidget(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String,
|
||||||
|
@DrawableRes logoRes: Int,
|
||||||
|
@ColorInt logoColor: Int,
|
||||||
|
checked: Boolean,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val highlighted = LocalPreferenceHighlighted.current
|
||||||
|
Box(modifier = Modifier.highlightBackground(highlighted)) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.background(color = Color(logoColor), shape = RoundedCornerShape(8.dp))
|
||||||
|
.padding(4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = logoRes),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
if (checked) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(32.dp),
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,139 @@
|
|||||||
|
package eu.kanade.presentation.more.settings.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CheckBox
|
||||||
|
import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
|
||||||
|
import androidx.compose.material.icons.rounded.DisabledByDefault
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.presentation.components.Divider
|
||||||
|
import eu.kanade.presentation.components.LazyColumn
|
||||||
|
import eu.kanade.presentation.util.isScrolledToEnd
|
||||||
|
import eu.kanade.presentation.util.isScrolledToStart
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
private enum class State {
|
||||||
|
CHECKED, INVERSED, UNCHECKED
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> TriStateListDialog(
|
||||||
|
title: String,
|
||||||
|
message: String? = null,
|
||||||
|
items: List<T>,
|
||||||
|
initialChecked: List<T>,
|
||||||
|
initialInversed: List<T>,
|
||||||
|
itemLabel: @Composable (T) -> String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onValueChanged: (newIncluded: List<T>, newExcluded: List<T>) -> Unit,
|
||||||
|
) {
|
||||||
|
val selected = remember {
|
||||||
|
items
|
||||||
|
.map {
|
||||||
|
when (it) {
|
||||||
|
in initialChecked -> State.CHECKED
|
||||||
|
in initialInversed -> State.INVERSED
|
||||||
|
else -> State.UNCHECKED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toMutableStateList()
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = { Text(text = title) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
LazyColumn(state = listState) {
|
||||||
|
itemsIndexed(items = items) { index, item ->
|
||||||
|
val state = selected[index]
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(25))
|
||||||
|
.clickable {
|
||||||
|
selected[index] = when (state) {
|
||||||
|
State.UNCHECKED -> State.CHECKED
|
||||||
|
State.CHECKED -> State.INVERSED
|
||||||
|
State.INVERSED -> State.UNCHECKED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.defaultMinSize(minHeight = 48.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.padding(end = 20.dp),
|
||||||
|
imageVector = when (state) {
|
||||||
|
State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank
|
||||||
|
State.CHECKED -> Icons.Rounded.CheckBox
|
||||||
|
State.INVERSED -> Icons.Rounded.DisabledByDefault
|
||||||
|
},
|
||||||
|
tint = if (state == State.UNCHECKED) {
|
||||||
|
LocalContentColor.current
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(text = itemLabel(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listState.isScrolledToStart()) Divider(modifier = Modifier.align(Alignment.TopCenter))
|
||||||
|
if (!listState.isScrolledToEnd()) Divider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(text = stringResource(id = android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val included = items.mapIndexedNotNull { index, category ->
|
||||||
|
if (selected[index] == State.CHECKED) category else null
|
||||||
|
}
|
||||||
|
val excluded = items.mapIndexedNotNull { index, category ->
|
||||||
|
if (selected[index] == State.INVERSED) category else null
|
||||||
|
}
|
||||||
|
onValueChanged(included, excluded)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -1,10 +1,15 @@
|
|||||||
package eu.kanade.presentation.theme
|
package eu.kanade.presentation.theme
|
||||||
|
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import com.google.android.material.composethemeadapter3.createMdc3Theme
|
import com.google.android.material.composethemeadapter3.createMdc3Theme
|
||||||
|
import eu.kanade.domain.ui.model.AppTheme
|
||||||
|
import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TachiyomiTheme(content: @Composable () -> Unit) {
|
fun TachiyomiTheme(content: @Composable () -> Unit) {
|
||||||
@ -22,3 +27,29 @@ fun TachiyomiTheme(content: @Composable () -> Unit) {
|
|||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TachiyomiTheme(
|
||||||
|
appTheme: AppTheme,
|
||||||
|
amoled: Boolean,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val originalContext = LocalContext.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
val themedContext = remember(appTheme, originalContext) {
|
||||||
|
val themeResIds = ThemingDelegate.getThemeResIds(appTheme, amoled)
|
||||||
|
themeResIds.fold(originalContext) { context, themeResId ->
|
||||||
|
ContextThemeWrapper(context, themeResId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val (colorScheme, typography) = createMdc3Theme(
|
||||||
|
context = themedContext,
|
||||||
|
layoutDirection = layoutDirection,
|
||||||
|
)
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme!!,
|
||||||
|
typography = typography!!,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -8,6 +8,16 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyListState.isScrolledToStart(): Boolean {
|
||||||
|
return remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val firstItem = layoutInfo.visibleItemsInfo.firstOrNull()
|
||||||
|
firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyListState.isScrolledToEnd(): Boolean {
|
fun LazyListState.isScrolledToEnd(): Boolean {
|
||||||
return remember {
|
return remember {
|
||||||
|
15
app/src/main/java/eu/kanade/presentation/util/Navigator.kt
Normal file
15
app/src/main/java/eu/kanade/presentation/util/Navigator.kt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package eu.kanade.presentation.util
|
||||||
|
|
||||||
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.bluelinelabs.conductor.Router
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For interop with Conductor
|
||||||
|
*/
|
||||||
|
val LocalRouter: ProvidableCompositionLocal<Router?> = staticCompositionLocalOf { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For invoking back press to the parent activity
|
||||||
|
*/
|
||||||
|
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
|
13
app/src/main/java/eu/kanade/presentation/util/Preference.kt
Normal file
13
app/src/main/java/eu/kanade/presentation/util/Preference.kt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.presentation.util
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import eu.kanade.tachiyomi.core.preference.Preference
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> Preference<T>.collectAsState(): State<T> {
|
||||||
|
val flow = remember(this) { changes() }
|
||||||
|
return flow.collectAsState(initial = get())
|
||||||
|
}
|
@ -56,6 +56,17 @@ abstract class BasicFullComposeController(bundle: Bundle? = null) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let Compose view handle this
|
||||||
|
override fun handleBack(): Boolean {
|
||||||
|
val dispatcher = (activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher ?: return false
|
||||||
|
return if (dispatcher.hasEnabledCallbacks()) {
|
||||||
|
dispatcher.onBackPressed()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposeContentController {
|
interface ComposeContentController {
|
||||||
|
@ -7,7 +7,6 @@ import eu.kanade.tachiyomi.ui.base.controller.RootController
|
|||||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryController
|
import eu.kanade.tachiyomi.ui.category.CategoryController
|
||||||
import eu.kanade.tachiyomi.ui.download.DownloadController
|
import eu.kanade.tachiyomi.ui.download.DownloadController
|
||||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupController
|
|
||||||
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
|
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
|
||||||
|
|
||||||
class MoreController :
|
class MoreController :
|
||||||
@ -22,7 +21,7 @@ class MoreController :
|
|||||||
presenter = presenter,
|
presenter = presenter,
|
||||||
onClickDownloadQueue = { router.pushController(DownloadController()) },
|
onClickDownloadQueue = { router.pushController(DownloadController()) },
|
||||||
onClickCategories = { router.pushController(CategoryController()) },
|
onClickCategories = { router.pushController(CategoryController()) },
|
||||||
onClickBackupAndRestore = { router.pushController(SettingsBackupController()) },
|
onClickBackupAndRestore = { router.pushController(SettingsMainController(toBackupScreen = true)) },
|
||||||
onClickSettings = { router.pushController(SettingsMainController()) },
|
onClickSettings = { router.pushController(SettingsMainController()) },
|
||||||
onClickAbout = { router.pushController(AboutController()) },
|
onClickAbout = { router.pushController(AboutController()) },
|
||||||
)
|
)
|
||||||
|
@ -1,85 +1,49 @@
|
|||||||
package eu.kanade.tachiyomi.ui.setting
|
package eu.kanade.tachiyomi.ui.setting
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import android.os.Bundle
|
||||||
import androidx.compose.material.icons.outlined.ChromeReaderMode
|
|
||||||
import androidx.compose.material.icons.outlined.Code
|
|
||||||
import androidx.compose.material.icons.outlined.GetApp
|
|
||||||
import androidx.compose.material.icons.outlined.Palette
|
|
||||||
import androidx.compose.material.icons.outlined.Security
|
|
||||||
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
|
||||||
import androidx.compose.material.icons.outlined.Sync
|
|
||||||
import androidx.compose.material.icons.outlined.Tune
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.core.os.bundleOf
|
||||||
import eu.kanade.presentation.more.settings.SettingsMainScreen
|
import cafe.adriel.voyager.core.stack.StackEvent
|
||||||
import eu.kanade.presentation.more.settings.SettingsSection
|
import cafe.adriel.voyager.navigator.Navigator
|
||||||
import eu.kanade.tachiyomi.R
|
import cafe.adriel.voyager.transitions.ScreenTransition
|
||||||
|
import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen
|
||||||
|
import eu.kanade.presentation.more.settings.screen.SettingsMainScreen
|
||||||
|
import eu.kanade.presentation.util.LocalBackPress
|
||||||
|
import eu.kanade.presentation.util.LocalRouter
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
import soup.compose.material.motion.animation.materialSharedAxisZ
|
||||||
import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchController
|
|
||||||
|
|
||||||
class SettingsMainController : BasicFullComposeController() {
|
class SettingsMainController : BasicFullComposeController {
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
constructor(bundle: Bundle) : this(bundle.getBoolean(TO_BACKUP_SCREEN))
|
||||||
|
|
||||||
|
constructor(toBackupScreen: Boolean = false) : super(bundleOf(TO_BACKUP_SCREEN to toBackupScreen))
|
||||||
|
|
||||||
|
private val toBackupScreen = args.getBoolean(TO_BACKUP_SCREEN)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun ComposeContent() {
|
override fun ComposeContent() {
|
||||||
val settingsSections = listOf(
|
Navigator(
|
||||||
SettingsSection(
|
screen = if (toBackupScreen) SettingsBackupScreen() else SettingsMainScreen,
|
||||||
titleRes = R.string.pref_category_general,
|
content = {
|
||||||
painter = rememberVectorPainter(Icons.Outlined.Tune),
|
CompositionLocalProvider(
|
||||||
onClick = { router.pushController(SettingsGeneralController()) },
|
LocalRouter provides router,
|
||||||
),
|
LocalBackPress provides this::back,
|
||||||
SettingsSection(
|
) {
|
||||||
titleRes = R.string.pref_category_appearance,
|
ScreenTransition(
|
||||||
painter = rememberVectorPainter(Icons.Outlined.Palette),
|
navigator = it,
|
||||||
onClick = { router.pushController(SettingsAppearanceController()) },
|
transition = { materialSharedAxisZ(forward = it.lastEvent != StackEvent.Pop) },
|
||||||
),
|
)
|
||||||
SettingsSection(
|
}
|
||||||
titleRes = R.string.pref_category_library,
|
},
|
||||||
painter = painterResource(R.drawable.ic_library_outline_24dp),
|
|
||||||
onClick = { router.pushController(SettingsLibraryController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.pref_category_reader,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.ChromeReaderMode),
|
|
||||||
onClick = { router.pushController(SettingsReaderController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.pref_category_downloads,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.GetApp),
|
|
||||||
onClick = { router.pushController(SettingsDownloadController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.pref_category_tracking,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.Sync),
|
|
||||||
onClick = { router.pushController(SettingsTrackingController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.browse,
|
|
||||||
painter = painterResource(R.drawable.ic_browse_outline_24dp),
|
|
||||||
onClick = { router.pushController(SettingsBrowseController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.label_backup,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.SettingsBackupRestore),
|
|
||||||
onClick = { router.pushController(SettingsBackupController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.pref_category_security,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.Security),
|
|
||||||
onClick = { router.pushController(SettingsSecurityController()) },
|
|
||||||
),
|
|
||||||
SettingsSection(
|
|
||||||
titleRes = R.string.pref_category_advanced,
|
|
||||||
painter = rememberVectorPainter(Icons.Outlined.Code),
|
|
||||||
onClick = { router.pushController(SettingsAdvancedController()) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
SettingsMainScreen(
|
|
||||||
navigateUp = router::popCurrentController,
|
|
||||||
sections = settingsSections,
|
|
||||||
onClickSearch = { router.pushController(SettingsSearchController()) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun back() {
|
||||||
|
activity?.onBackPressed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val TO_BACKUP_SCREEN = "to_backup_screen"
|
||||||
|
@ -10,6 +10,9 @@ import androidx.biometric.auth.AuthPromptCallback
|
|||||||
import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
|
import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
object AuthenticatorUtil {
|
object AuthenticatorUtil {
|
||||||
|
|
||||||
@ -43,6 +46,45 @@ object AuthenticatorUtil {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun FragmentActivity.authenticate(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = getString(R.string.confirm_lock_change),
|
||||||
|
): Boolean = suspendCancellableCoroutine { cont ->
|
||||||
|
if (!isAuthenticationSupported()) {
|
||||||
|
cont.resume(true)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
startAuthentication(
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
callback = object : AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
activity: FragmentActivity?,
|
||||||
|
result: BiometricPrompt.AuthenticationResult,
|
||||||
|
) {
|
||||||
|
super.onAuthenticationSucceeded(activity, result)
|
||||||
|
cont.resume(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
activity: FragmentActivity?,
|
||||||
|
errorCode: Int,
|
||||||
|
errString: CharSequence,
|
||||||
|
) {
|
||||||
|
super.onAuthenticationError(activity, errorCode, errString)
|
||||||
|
activity?.toast(errString.toString())
|
||||||
|
cont.resume(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed(activity: FragmentActivity?) {
|
||||||
|
super.onAuthenticationFailed(activity)
|
||||||
|
cont.resume(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if Class 2 biometric or credential lock is set and available to use
|
* Returns true if Class 2 biometric or credential lock is set and available to use
|
||||||
*/
|
*/
|
||||||
|
@ -69,6 +69,6 @@ class NetworkHelper(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val defaultUserAgent by lazy {
|
val defaultUserAgent by lazy {
|
||||||
preferences.defaultUserAgent().get()
|
preferences.defaultUserAgent().get().trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,3 +22,4 @@ accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiper
|
|||||||
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
||||||
accompanist-pager-core = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" }
|
accompanist-pager-core = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" }
|
||||||
accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
|
accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
|
||||||
|
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
|
||||||
|
@ -8,6 +8,7 @@ flowbinding_version = "1.2.0"
|
|||||||
shizuku_version = "12.2.0"
|
shizuku_version = "12.2.0"
|
||||||
sqldelight = "1.5.4"
|
sqldelight = "1.5.4"
|
||||||
leakcanary = "2.9.1"
|
leakcanary = "2.9.1"
|
||||||
|
voyager = "1.0.0-beta16"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
|
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
|
||||||
@ -90,6 +91,12 @@ sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version.
|
|||||||
|
|
||||||
junit = "org.junit.jupiter:junit-jupiter:5.9.1"
|
junit = "org.junit.jupiter:junit-jupiter:5.9.1"
|
||||||
|
|
||||||
|
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
|
||||||
|
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
|
||||||
|
|
||||||
|
materialmotion-core = "io.github.fornewid:material-motion-compose-core:0.10.2-beta"
|
||||||
|
numberpicker= "com.chargemap.compose:numberpicker:1.0.3"
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
reactivex = ["rxandroid", "rxjava", "rxrelay"]
|
reactivex = ["rxandroid", "rxjava", "rxrelay"]
|
||||||
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
|
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
|
||||||
@ -100,6 +107,7 @@ coil = ["coil-core", "coil-gif", "coil-compose"]
|
|||||||
flowbinding = ["flowbinding-android", "flowbinding-appcompat"]
|
flowbinding = ["flowbinding-android", "flowbinding-appcompat"]
|
||||||
conductor = ["conductor-core", "conductor-support-preference"]
|
conductor = ["conductor-core", "conductor-support-preference"]
|
||||||
shizuku = ["shizuku-api", "shizuku-provider"]
|
shizuku = ["shizuku-api", "shizuku-provider"]
|
||||||
|
voyager = ["voyager-navigator", "voyager-transitions"]
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlinter = { id = "org.jmailen.kotlinter", version = "3.12.0" }
|
kotlinter = { id = "org.jmailen.kotlinter", version = "3.12.0" }
|
||||||
|
Loading…
Reference in New Issue
Block a user