Преглед изворни кода

Full Compose settings (#8201)

* Uses Voyager for navigation.
* Replaces every screen inside settings except category editor screen since it's
called from several places.
Ivan Iskandar пре 2 година
родитељ
комит
890f1a3c7b
42 измењених фајлова са 4903 додато и 79 уклоњено
  1. 9 3
      app/build.gradle.kts
  2. 2 0
      app/src/main/java/eu/kanade/domain/base/BasePreferences.kt
  3. 168 0
      app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt
  4. 146 0
      app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt
  5. 31 0
      app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt
  6. 100 0
      app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt
  7. 218 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt
  8. 47 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt
  9. 42 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt
  10. 398 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt
  11. 142 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt
  12. 370 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt
  13. 79 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt
  14. 269 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt
  15. 108 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt
  16. 360 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt
  17. 112 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt
  18. 312 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt
  19. 303 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt
  20. 89 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt
  21. 336 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt
  22. 270 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt
  23. 176 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt
  24. 79 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt
  25. 105 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt
  26. 99 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt
  27. 28 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt
  28. 69 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt
  29. 50 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt
  30. 77 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt
  31. 139 0
      app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt
  32. 31 0
      app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt
  33. 10 0
      app/src/main/java/eu/kanade/presentation/util/LazyListState.kt
  34. 15 0
      app/src/main/java/eu/kanade/presentation/util/Navigator.kt
  35. 13 0
      app/src/main/java/eu/kanade/presentation/util/Preference.kt
  36. 11 0
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt
  37. 1 2
      app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt
  38. 37 73
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt
  39. 42 0
      app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt
  40. 1 1
      core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
  41. 1 0
      gradle/compose.versions.toml
  42. 8 0
      gradle/libs.versions.toml

+ 9 - 3
app/build.gradle.kts

@@ -141,12 +141,12 @@ android {
     }
 
     compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_1_8
-        targetCompatibility = JavaVersion.VERSION_1_8
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
     }
 
     kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_1_8.toString()
+        jvmTarget = JavaVersion.VERSION_11.toString()
     }
 
     sqldelight {
@@ -178,6 +178,7 @@ dependencies {
     implementation(compose.accompanist.flowlayout)
     implementation(compose.accompanist.pager.core)
     implementation(compose.accompanist.pager.indicators)
+    implementation(compose.accompanist.permissions)
 
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)
@@ -264,6 +265,9 @@ dependencies {
     implementation(libs.markwon)
     implementation(libs.aboutLibraries.compose)
     implementation(libs.cascade)
+    implementation(libs.numberpicker)
+    implementation(libs.bundles.voyager)
+    implementation(libs.materialmotion.core)
 
     // Conductor
     implementation(libs.bundles.conductor)
@@ -315,10 +319,12 @@ tasks {
         kotlinOptions.freeCompilerArgs += listOf(
             "-opt-in=coil.annotation.ExperimentalCoilApi",
             "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi",
+            "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
             "-opt-in=androidx.compose.material.ExperimentalMaterialApi",
             "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
             "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
             "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
+            "-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
             "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
             "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
             "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",

+ 2 - 0
app/src/main/java/eu/kanade/domain/base/BasePreferences.kt

@@ -23,4 +23,6 @@ class BasePreferences(
         "extension_installer",
         if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER,
     )
+
+    fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true)
 }

+ 168 - 0
app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt

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

+ 146 - 0
app/src/main/java/eu/kanade/presentation/more/settings/PreferenceModel.kt

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

+ 31 - 0
app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScaffold.kt

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

+ 100 - 0
app/src/main/java/eu/kanade/presentation/more/settings/PreferenceScreen.kt

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

+ 218 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/ClearDatabaseScreen.kt

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

+ 47 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/Commons.kt

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

+ 42 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SearchableSettings.kt

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

+ 398 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt

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

+ 142 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt

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

+ 370 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt

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

+ 79 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt

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

+ 269 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt

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

+ 108 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsGeneralScreen.kt

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

+ 360 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt

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

+ 112 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt

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

+ 312 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt

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

+ 303 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt

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

+ 89 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt

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

+ 336 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt

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

+ 270 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/AppThemePreferenceWidget.kt

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

+ 176 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/BasePreferenceWidget.kt

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

+ 79 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/EditTextPreferenceWidget.kt

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

+ 105 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/ListPreferenceWidget.kt

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

+ 99 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/MultiSelectListPreferenceWidget.kt

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

+ 28 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/PreferenceGroupHeader.kt

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

+ 69 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/SwitchPreferenceWidget.kt

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

+ 50 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/TextPreferenceWidget.kt

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

+ 77 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/TrackingPreferenceWidget.kt

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

+ 139 - 0
app/src/main/java/eu/kanade/presentation/more/settings/widget/TriStateListDialog.kt

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

+ 31 - 0
app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt

@@ -1,10 +1,15 @@
 package eu.kanade.presentation.theme
 
+import androidx.appcompat.view.ContextThemeWrapper
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalLayoutDirection
 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
 fun TachiyomiTheme(content: @Composable () -> Unit) {
@@ -22,3 +27,29 @@ fun TachiyomiTheme(content: @Composable () -> Unit) {
         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,
+    )
+}

+ 10 - 0
app/src/main/java/eu/kanade/presentation/util/LazyListState.kt

@@ -8,6 +8,16 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 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
 fun LazyListState.isScrolledToEnd(): Boolean {
     return remember {

+ 15 - 0
app/src/main/java/eu/kanade/presentation/util/Navigator.kt

@@ -0,0 +1,15 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.bluelinelabs.conductor.Router
+
+/**
+ * For interop with Conductor
+ */
+val LocalRouter: ProvidableCompositionLocal<Router?> = staticCompositionLocalOf { null }
+
+/**
+ * For invoking back press to the parent activity
+ */
+val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }

+ 13 - 0
app/src/main/java/eu/kanade/presentation/util/Preference.kt

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

+ 11 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt

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

+ 1 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt

@@ -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.category.CategoryController
 import eu.kanade.tachiyomi.ui.download.DownloadController
-import eu.kanade.tachiyomi.ui.setting.SettingsBackupController
 import eu.kanade.tachiyomi.ui.setting.SettingsMainController
 
 class MoreController :
@@ -22,7 +21,7 @@ class MoreController :
             presenter = presenter,
             onClickDownloadQueue = { router.pushController(DownloadController()) },
             onClickCategories = { router.pushController(CategoryController()) },
-            onClickBackupAndRestore = { router.pushController(SettingsBackupController()) },
+            onClickBackupAndRestore = { router.pushController(SettingsMainController(toBackupScreen = true)) },
             onClickSettings = { router.pushController(SettingsMainController()) },
             onClickAbout = { router.pushController(AboutController()) },
         )

+ 37 - 73
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt

@@ -1,85 +1,49 @@
 package eu.kanade.tachiyomi.ui.setting
 
-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.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 android.os.Bundle
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.vector.rememberVectorPainter
-import androidx.compose.ui.res.painterResource
-import eu.kanade.presentation.more.settings.SettingsMainScreen
-import eu.kanade.presentation.more.settings.SettingsSection
-import eu.kanade.tachiyomi.R
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.core.os.bundleOf
+import cafe.adriel.voyager.core.stack.StackEvent
+import cafe.adriel.voyager.navigator.Navigator
+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.pushController
-import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchController
+import soup.compose.material.motion.animation.materialSharedAxisZ
 
-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
     override fun ComposeContent() {
-        val settingsSections = listOf(
-            SettingsSection(
-                titleRes = R.string.pref_category_general,
-                painter = rememberVectorPainter(Icons.Outlined.Tune),
-                onClick = { router.pushController(SettingsGeneralController()) },
-            ),
-            SettingsSection(
-                titleRes = R.string.pref_category_appearance,
-                painter = rememberVectorPainter(Icons.Outlined.Palette),
-                onClick = { router.pushController(SettingsAppearanceController()) },
-            ),
-            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()) },
-            ),
+        Navigator(
+            screen = if (toBackupScreen) SettingsBackupScreen() else SettingsMainScreen,
+            content = {
+                CompositionLocalProvider(
+                    LocalRouter provides router,
+                    LocalBackPress provides this::back,
+                ) {
+                    ScreenTransition(
+                        navigator = it,
+                        transition = { materialSharedAxisZ(forward = it.lastEvent != StackEvent.Pop) },
+                    )
+                }
+            },
         )
+    }
 
-        SettingsMainScreen(
-            navigateUp = router::popCurrentController,
-            sections = settingsSections,
-            onClickSearch = { router.pushController(SettingsSearchController()) },
-        )
+    private fun back() {
+        activity?.onBackPressed()
     }
 }
+
+private const val TO_BACKUP_SCREEN = "to_backup_screen"

+ 42 - 0
app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt

@@ -10,6 +10,9 @@ import androidx.biometric.auth.AuthPromptCallback
 import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
 import androidx.core.content.ContextCompat
 import androidx.fragment.app.FragmentActivity
+import eu.kanade.tachiyomi.R
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
 
 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
      */

+ 1 - 1
core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt

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

+ 1 - 0
gradle/compose.versions.toml

@@ -22,3 +22,4 @@ accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiper
 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-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }

+ 8 - 0
gradle/libs.versions.toml

@@ -8,6 +8,7 @@ flowbinding_version = "1.2.0"
 shizuku_version = "12.2.0"
 sqldelight = "1.5.4"
 leakcanary = "2.9.1"
+voyager = "1.0.0-beta16"
 
 [libraries]
 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"
 
+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]
 reactivex = ["rxandroid", "rxjava", "rxrelay"]
 okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
@@ -100,6 +107,7 @@ coil = ["coil-core", "coil-gif", "coil-compose"]
 flowbinding = ["flowbinding-android", "flowbinding-appcompat"]
 conductor = ["conductor-core", "conductor-support-preference"]
 shizuku = ["shizuku-api", "shizuku-provider"]
+voyager = ["voyager-navigator", "voyager-transitions"]
 
 [plugins]
 kotlinter = { id = "org.jmailen.kotlinter", version = "3.12.0" }