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:
Ivan Iskandar 2022-10-15 22:38:01 +07:00 committed by GitHub
parent 3fdcd636d7
commit 890f1a3c7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 4904 additions and 80 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,6 +69,6 @@ class NetworkHelper(context: Context) {
} }
val defaultUserAgent by lazy { val defaultUserAgent by lazy {
preferences.defaultUserAgent().get() preferences.defaultUserAgent().get().trim()
} }
} }

View file

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

View file

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