Просмотр исходного кода

[新增] 初步用 Compose 重写高级设置界面,可在高级-测试初步体验。

HapeLee 9 месяцев назад
Родитель
Сommit
f21ff42e4c
30 измененных файлов с 2061 добавлено и 263 удалено
  1. 4 0
      app/src/main/AndroidManifest.xml
  2. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  3. 76 26
      app/src/main/java/io/legado/app/help/config/ThemeConfig.kt
  4. 1 1
      app/src/main/java/io/legado/app/ui/about/AboutScreen.kt
  5. 0 2
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  6. 2 1
      app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt
  7. 53 0
      app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt
  8. 13 0
      app/src/main/java/io/legado/app/ui/config/TestConfigActivity.kt
  9. 153 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
  10. 218 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
  11. 183 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt
  12. 512 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt
  13. 157 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  14. 0 2
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  15. 1 1
      app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
  16. 0 2
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  17. 0 182
      app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt
  18. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
  19. 40 0
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
  20. 27 0
      app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxGroupContainer.kt
  21. 57 0
      app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
  22. 44 41
      app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt
  23. 35 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
  24. 92 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt
  25. 53 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt
  26. 170 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/SettingItem.kt
  27. 130 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
  28. 27 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt
  29. 4 3
      app/src/main/res/values-zh-rCN/strings.xml
  30. 5 0
      app/src/main/res/xml/pref_config_other.xml

+ 4 - 0
app/src/main/AndroidManifest.xml

@@ -216,6 +216,10 @@
             android:name=".ui.config.ConfigActivity"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="standard" />
+        <activity
+            android:name=".ui.config.TestConfigActivity"
+            android:enableOnBackInvokedCallback="true"
+            android:launchMode="standard" />
         <!-- 搜索界面 -->
         <activity
             android:name=".ui.book.search.SearchActivity"

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -13,6 +13,7 @@ import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
+import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.main.my.MyViewModel
 import io.legado.app.ui.replace.ReplaceEditRoute
@@ -43,6 +44,7 @@ val appModule = module {
     viewModelOf(::ReplaceRuleViewModel)
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
+    viewModelOf(::OtherConfigViewModel)
 
     viewModel { (route: ReplaceEditRoute) ->
         ReplaceEditViewModel(

+ 76 - 26
app/src/main/java/io/legado/app/help/config/ThemeConfig.kt

@@ -190,6 +190,48 @@ object ThemeConfig {
     }
 
     fun saveDayTheme(context: Context, name: String) {
+        val config = getDayTheme(context, name)
+        addConfig(config)
+    }
+
+    fun saveNightTheme(context: Context, name: String) {
+        val config = getNightTheme(context, name)
+        addConfig(config)
+    }
+
+    /**
+     * 更新主题
+     */
+    fun clearBg() {
+        val bgImagePath = appCtx.getPrefString(PreferKey.bgImage)
+        appCtx.externalFiles.getFile(PreferKey.bgImage).listFiles()?.forEach {
+            if (it.absolutePath != bgImagePath) {
+                it.delete()
+            }
+        }
+        val bgImageNPath = appCtx.getPrefString(PreferKey.bgImageN)
+        appCtx.externalFiles.getFile(PreferKey.bgImageN).listFiles()?.forEach {
+            if (it.absolutePath != bgImageNPath) {
+                it.delete()
+            }
+        }
+    }
+
+    fun getDurConfig(context: Context): Config {
+        val isNight = AppConfig.isNightTheme
+        val name = if (isNight) {
+            "MD3-Night"
+        } else {
+            "MD3-Day"
+        }
+        return if (isNight) {
+            getNightTheme(context, name)
+        } else {
+            getDayTheme(context, name)
+        }
+    }
+
+    private fun getDayTheme(context: Context, name: String): Config {
         val primary =
             context.getPrefInt(PreferKey.cPrimary, context.getCompatColor(R.color.md_brown_500))
         val accent =
@@ -198,18 +240,24 @@ object ThemeConfig {
             context.getPrefInt(PreferKey.cBackground, context.getCompatColor(R.color.md_grey_100))
         val bBackground =
             context.getPrefInt(PreferKey.cBBackground, context.getCompatColor(R.color.md_grey_200))
-        val config = Config(
+        val bgImgPath =
+            context.getPrefString(PreferKey.bgImage)
+        val bgImgBlur =
+            context.getPrefInt(PreferKey.bgImageBlurring, 0)
+
+        return Config(
             themeName = name,
             isNightTheme = false,
             primaryColor = "#${primary.hexString}",
             accentColor = "#${accent.hexString}",
             backgroundColor = "#${background.hexString}",
-            bottomBackground = "#${bBackground.hexString}"
+            bottomBackground = "#${bBackground.hexString}",
+            backgroundImgPath = bgImgPath,
+            backgroundImgBlur = bgImgBlur
         )
-        addConfig(config)
     }
 
-    fun saveNightTheme(context: Context, name: String) {
+    private fun getNightTheme(context: Context, name: String): Config {
         val primary =
             context.getPrefInt(
                 PreferKey.cNPrimary,
@@ -224,33 +272,20 @@ object ThemeConfig {
             context.getPrefInt(PreferKey.cNBackground, context.getCompatColor(R.color.md_grey_900))
         val bBackground =
             context.getPrefInt(PreferKey.cNBBackground, context.getCompatColor(R.color.md_grey_850))
-        val config = Config(
+        val bgImgPath =
+            context.getPrefString(PreferKey.bgImageN)
+        val bgImgBlur =
+            context.getPrefInt(PreferKey.bgImageNBlurring, 0)
+        return Config(
             themeName = name,
             isNightTheme = true,
             primaryColor = "#${primary.hexString}",
             accentColor = "#${accent.hexString}",
             backgroundColor = "#${background.hexString}",
-            bottomBackground = "#${bBackground.hexString}"
+            bottomBackground = "#${bBackground.hexString}",
+            backgroundImgPath = bgImgPath,
+            backgroundImgBlur = bgImgBlur
         )
-        addConfig(config)
-    }
-
-    /**
-     * 更新主题
-     */
-    fun clearBg() {
-        val bgImagePath = appCtx.getPrefString(PreferKey.bgImage)
-        appCtx.externalFiles.getFile(PreferKey.bgImage).listFiles()?.forEach {
-            if (it.absolutePath != bgImagePath) {
-                it.delete()
-            }
-        }
-        val bgImageNPath = appCtx.getPrefString(PreferKey.bgImageN)
-        appCtx.externalFiles.getFile(PreferKey.bgImageN).listFiles()?.forEach {
-            if (it.absolutePath != bgImageNPath) {
-                it.delete()
-            }
-        }
     }
 
     @Keep
@@ -260,7 +295,9 @@ object ThemeConfig {
         var primaryColor: String,
         var accentColor: String,
         var backgroundColor: String,
-        var bottomBackground: String
+        var bottomBackground: String,
+        var backgroundImgPath: String?,
+        var backgroundImgBlur: Int
     ) {
 
         override fun hashCode(): Int {
@@ -276,10 +313,23 @@ object ThemeConfig {
                         && other.accentColor == accentColor
                         && other.backgroundColor == backgroundColor
                         && other.bottomBackground == bottomBackground
+                        && other.backgroundImgPath == backgroundImgPath
+                        && other.backgroundImgBlur == backgroundImgBlur
             }
             return false
         }
 
+        fun toMap() = mapOf(
+            "themeName" to themeName,
+            "isNightTheme" to isNightTheme,
+            "primaryColor" to primaryColor,
+            "accentColor" to accentColor,
+            "backgroundColor" to backgroundColor,
+            "bottomBackground" to bottomBackground,
+            "backgroundImgPath" to backgroundImgPath,
+            "backgroundImgBlur" to backgroundImgBlur
+        )
+
     }
 
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/about/AboutScreen.kt

@@ -34,10 +34,10 @@ import androidx.compose.ui.unit.sp
 import io.legado.app.R
 import io.legado.app.constant.AppConst.appInfo
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
-import io.legado.app.ui.widget.components.SettingItem
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.button.SmallTopBarButton
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.settingItem.SettingItem
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable

+ 0 - 2
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -154,12 +154,10 @@ fun TxtRuleScreen(
         FilePickerSheet(
             sheetState = sheetState,
             onDismissRequest = { showFilePickerSheet = false },
-            mode = filePickerMode,
             onSelectSysDir = {
                 showFilePickerSheet = false
                 exportDoc.launch("exportDictRule.json")
             },
-            onSelectSysFile = {},
             onUpload = {
                 showFilePickerSheet = false
                 viewModel.uploadSelectedRules(selectedIds, rules)

+ 2 - 1
app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt

@@ -42,6 +42,7 @@ import io.legado.app.utils.putPrefString
 import io.legado.app.utils.removePref
 import io.legado.app.utils.restart
 import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.startActivity
 import splitties.init.appCtx
 
 /**
@@ -168,7 +169,7 @@ class OtherConfigFragment : PreferenceFragment(),
             PreferKey.clearWebViewData -> clearWebViewData()
             "localPassword" -> alertLocalPassword()
             PreferKey.shrinkDatabase -> shrinkDatabase()
-
+            "TEST" -> startActivity<TestConfigActivity>()
             PreferKey.notificationsPost -> checkPermission(1)
             PreferKey.ignoreBatteryPermission -> checkPermission(2)
         }

+ 53 - 0
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -0,0 +1,53 @@
+package io.legado.app.ui.config
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import io.legado.app.utils.getPrefBoolean
+import io.legado.app.utils.getPrefInt
+import io.legado.app.utils.getPrefLong
+import io.legado.app.utils.getPrefString
+import io.legado.app.utils.putPrefBoolean
+import io.legado.app.utils.putPrefInt
+import io.legado.app.utils.putPrefLong
+import io.legado.app.utils.putPrefString
+import splitties.init.appCtx
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+
+fun <T> prefDelegate(
+    key: String,
+    defaultValue: T,
+    onValueChange: ((T) -> Unit)? = null
+): ReadWriteProperty<Any?, T> {
+    return object : ReadWriteProperty<Any?, T> {
+        private var _value: MutableState<T> = mutableStateOf(readInitialValue())
+
+        @Suppress("UNCHECKED_CAST")
+        private fun readInitialValue(): T {
+            return when (defaultValue) {
+                is String -> appCtx.getPrefString(key, defaultValue) as T
+                is Int -> appCtx.getPrefInt(key, defaultValue) as T
+                is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T
+                is Long -> appCtx.getPrefLong(key, defaultValue) as T
+                else -> defaultValue
+            }
+        }
+
+        override fun getValue(thisRef: Any?, property: KProperty<*>): T {
+            return _value.value
+        }
+
+        override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+            if (_value.value != value) {
+                when (value) {
+                    is String -> appCtx.putPrefString(key, value)
+                    is Int -> appCtx.putPrefInt(key, value)
+                    is Boolean -> appCtx.putPrefBoolean(key, value)
+                    is Long -> appCtx.putPrefLong(key, value)
+                }
+                _value.value = value
+                onValueChange?.invoke(value)
+            }
+        }
+    }
+}

+ 13 - 0
app/src/main/java/io/legado/app/ui/config/TestConfigActivity.kt

@@ -0,0 +1,13 @@
+package io.legado.app.ui.config
+
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.ui.config.otherConfig.OtherConfigScreen
+
+class TestConfigActivity : BaseComposeActivity() {
+    @Composable
+    override fun Content() {
+        OtherConfigScreen(onBackClick = { finish() })
+    }
+
+}

+ 153 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt

@@ -0,0 +1,153 @@
+package io.legado.app.ui.config.otherConfig
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
+import io.legado.app.ui.widget.components.checkBox.CheckboxItem
+import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CheckSourceBottomSheet(
+    viewModel: OtherConfigViewModel = koinViewModel(),
+    onDismiss: () -> Unit
+) {
+    val context = LocalContext.current
+    val sheetState = rememberModalBottomSheetState(
+        skipPartiallyExpanded = true
+    )
+
+    GlassModalBottomSheet(
+        onDismissRequest = onDismiss,
+        sheetState = sheetState
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp)
+                .padding(bottom = 32.dp)
+        ) {
+            Text(
+                text = stringResource(R.string.check_source_config),
+                style = MaterialTheme.typography.titleLarge,
+                modifier = Modifier.padding(vertical = 16.dp)
+            )
+
+            SliderSettingItem(
+                title = stringResource(R.string.check_source_timeout),
+                color = MaterialTheme.colorScheme.surface,
+                value = viewModel.checkSourceTimeout.toFloat(),
+                defaultValue = 180f,
+                onValueChange = { viewModel.checkSourceTimeout = it.toLong() },
+                valueRange = 0f..300f,
+            )
+
+            Spacer(modifier = Modifier.padding(8.dp))
+
+            CheckboxGroupContainer(columns = 2) {
+                item {
+                    CheckboxItem(
+                        title = stringResource(R.string.search),
+                        checked = viewModel.checkSearch,
+                        onCheckedChange = {
+                            viewModel.checkSearch = it
+                            if (!it && !viewModel.checkDiscovery) {
+                                viewModel.checkDiscovery = true
+                            }
+                        }
+                    )
+                }
+
+                item {
+                    CheckboxItem(
+                        title = stringResource(R.string.discovery),
+                        checked = viewModel.checkDiscovery,
+                        onCheckedChange = {
+                            viewModel.checkDiscovery = it
+                            if (!it && !viewModel.checkSearch) {
+                                viewModel.checkSearch = true
+                            }
+                        }
+                    )
+                }
+            }
+
+
+            CheckboxGroupContainer(columns = 3) {
+                item {
+                    CheckboxItem(
+                        title = stringResource(R.string.source_tab_info),
+                        checked = viewModel.checkInfo,
+                        onCheckedChange = {
+                            viewModel.checkInfo = it
+                            if (!it) {
+                                viewModel.checkCategory = false
+                                viewModel.checkContent = false
+                            }
+                        }
+                    )
+                }
+
+                item {
+                    CheckboxItem(
+                        title = stringResource(R.string.chapter_list),
+                        checked = viewModel.checkCategory,
+                        enabled = viewModel.checkInfo,
+                        onCheckedChange = {
+                            viewModel.checkCategory = it
+                            if (!it) viewModel.checkContent = false
+                        }
+                    )
+                }
+
+                item {
+                    CheckboxItem(
+                        title = stringResource(R.string.source_tab_content),
+                        checked = viewModel.checkContent,
+                        enabled = viewModel.checkCategory,
+                        onCheckedChange = { viewModel.checkContent = it }
+                    )
+                }
+            }
+
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(top = 16.dp),
+                horizontalArrangement = Arrangement.End
+            ) {
+                TextButton(onClick = onDismiss) {
+                    Text(stringResource(R.string.cancel))
+                }
+                Button(onClick = {
+                    if (viewModel.saveCheckSourceConfig()) {
+                        onDismiss()
+                    } else {
+                        Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show()
+                    }
+                }) {
+                    Text(stringResource(R.string.ok))
+                }
+            }
+        }
+    }
+}

+ 218 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt

@@ -0,0 +1,218 @@
+package io.legado.app.ui.config.otherConfig
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+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.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.help.DirectLinkUpload
+import io.legado.app.lib.dialogs.selector
+import io.legado.app.ui.widget.components.checkBox.CheckboxItem
+import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getClipText
+import io.legado.app.utils.sendToClip
+import io.legado.app.utils.toastOnUi
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DirectLinkUploadBottomSheet(
+    viewModel: OtherConfigViewModel,
+    onDismiss: () -> Unit
+) {
+    val context = LocalContext.current
+    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+    var showMenu by remember { mutableStateOf(false) }
+    var showTestResult by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(Unit) {
+        viewModel.initDirectLinkRule()
+    }
+
+    GlassModalBottomSheet(
+        onDismissRequest = onDismiss,
+        sheetState = sheetState,
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp)
+                .padding(bottom = 24.dp)
+                .verticalScroll(rememberScrollState())
+        ) {
+            CenterAlignedTopAppBar(
+                title = {
+                    Text(
+                        text = stringResource(R.string.direct_link_upload_config),
+                        style = MaterialTheme.typography.titleLarge
+                    )
+                },
+                actions = {
+                    IconButton(onClick = { showMenu = true }) {
+                        Icon(Icons.Default.MoreVert, contentDescription = "More")
+                    }
+                    DropdownMenu(
+                        expanded = showMenu,
+                        onDismissRequest = { showMenu = false }
+                    ) {
+                        DropdownMenuItem(
+                            text = { Text("导入默认") },
+                            leadingIcon = { Icon(Icons.Default.Download, null) },
+                            onClick = {
+                                showMenu = false
+                                context.selector(DirectLinkUpload.defaultRules) { _, rule, _ ->
+                                    viewModel.upView(rule)
+                                }
+                            }
+                        )
+                        DropdownMenuItem(
+                            text = { Text(stringResource(R.string.copy_rule)) },
+                            leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
+                            onClick = {
+                                showMenu = false
+                                val rule = DirectLinkUpload.Rule(
+                                    viewModel.uploadUrl,
+                                    viewModel.downloadUrlRule,
+                                    viewModel.summary,
+                                    viewModel.compress
+                                )
+                                context.sendToClip(GSON.toJson(rule))
+                            }
+                        )
+                        DropdownMenuItem(
+                            text = { Text(stringResource(R.string.paste_rule)) },
+                            leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
+                            onClick = {
+                                showMenu = false
+                                runCatching {
+                                    context.getClipText()?.let {
+                                        val rule =
+                                            GSON.fromJsonObject<DirectLinkUpload.Rule>(it)
+                                                .getOrThrow()
+                                        viewModel.upView(rule)
+                                    }
+                                }.onFailure {
+                                    context.toastOnUi("剪贴板为空或格式不对")
+                                }
+                            }
+                        )
+                    }
+                },
+                colors = TopAppBarDefaults.topAppBarColors(
+                    containerColor = Color.Transparent
+                )
+            )
+
+            Spacer(Modifier.height(16.dp))
+
+            OutlinedTextField(
+                value = viewModel.uploadUrl,
+                onValueChange = { viewModel.uploadUrl = it },
+                label = { Text(stringResource(R.string.upload_url)) },
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Spacer(Modifier.height(16.dp))
+
+            OutlinedTextField(
+                value = viewModel.downloadUrlRule,
+                onValueChange = { viewModel.downloadUrlRule = it },
+                label = { Text(stringResource(R.string.download_url_rule)) },
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Spacer(Modifier.height(16.dp))
+
+            OutlinedTextField(
+                value = viewModel.summary,
+                onValueChange = { viewModel.summary = it },
+                label = { Text(stringResource(R.string.summary)) },
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Spacer(Modifier.height(16.dp))
+
+            CheckboxItem(
+                title = stringResource(R.string.is_compress),
+                color = MaterialTheme.colorScheme.surface,
+                checked = viewModel.compress,
+                onCheckedChange = { viewModel.compress = it }
+            )
+
+            Spacer(Modifier.height(24.dp))
+
+            Row(modifier = Modifier.fillMaxWidth()) {
+                TextButton(onClick = {
+                    viewModel.testRule { result -> showTestResult = result }
+                }) {
+                    Text("测试")
+                }
+                Spacer(Modifier.weight(1f))
+                OutlinedButton(onClick = onDismiss) {
+                    Text(stringResource(R.string.cancel))
+                }
+                Button(onClick = {
+                    if (viewModel.saveDirectLinkRule()) {
+                        onDismiss()
+                    } else {
+                        context.toastOnUi("请填写完整信息")
+                    }
+                }) {
+                    Text(stringResource(R.string.ok))
+                }
+            }
+        }
+    }
+
+    showTestResult?.let { result ->
+        AlertDialog(
+            onDismissRequest = { showTestResult = null },
+            title = { Text("Result") },
+            text = { Text(result) },
+            confirmButton = {
+                TextButton(onClick = {
+                    showTestResult = null
+                }) { Text(stringResource(R.string.ok)) }
+            },
+            dismissButton = {
+                TextButton(onClick = { context.sendToClip(result) }) { Text(stringResource(R.string.copy_text)) }
+            }
+        )
+    }
+}

+ 183 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt

@@ -0,0 +1,183 @@
+package io.legado.app.ui.config.otherConfig
+
+import io.legado.app.BuildConfig
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefDelegate
+
+object OtherConfig {
+
+    var language by prefDelegate(
+        PreferKey.language,
+        "auto"
+    )
+
+    var updateToVariant by prefDelegate(
+        PreferKey.updateToVariant,
+        "official_version"
+    )
+
+    var webServiceAutoStart by prefDelegate(
+        PreferKey.webServiceAutoStart,
+        false
+    )
+
+    var autoRefresh by prefDelegate(
+        PreferKey.autoRefresh,
+        false
+    )
+
+    var defaultToRead by prefDelegate(
+        PreferKey.defaultToRead,
+        false
+    )
+
+    var notificationsPost by prefDelegate(
+        PreferKey.notificationsPost,
+        true
+    )
+
+    var ignoreBatteryPermission by prefDelegate(
+        PreferKey.ignoreBatteryPermission,
+        true
+    )
+
+    var firebaseEnable by prefDelegate(
+        PreferKey.firebaseEnable,
+        true
+    )
+
+    var defaultBookTreeUri by prefDelegate(
+        PreferKey.defaultBookTreeUri,
+        ""
+    )
+
+    var antiAlias by prefDelegate(
+        PreferKey.antiAlias,
+        false
+    )
+
+    var bitmapCacheSize by prefDelegate(
+        PreferKey.bitmapCacheSize,
+        50
+    )
+
+    var imageRetainNum by prefDelegate(
+        PreferKey.imageRetainNum,
+        0
+    )
+
+    var preDownloadNum by prefDelegate(
+        PreferKey.preDownloadNum,
+        10
+    )
+
+    var replaceEnableDefault by prefDelegate(
+        PreferKey.replaceEnableDefault,
+        true
+    )
+
+    var mediaButtonOnExit by prefDelegate(
+        PreferKey.mediaButtonOnExit,
+        true
+    )
+
+    var readAloudByMediaButton by prefDelegate(
+        PreferKey.readAloudByMediaButton,
+        false
+    )
+
+    var ignoreAudioFocus by prefDelegate(
+        PreferKey.ignoreAudioFocus,
+        false
+    )
+
+    var autoClearExpired by prefDelegate(
+        PreferKey.autoClearExpired,
+        true
+    )
+
+    var showAddToShelfAlert by prefDelegate(
+        PreferKey.showAddToShelfAlert,
+        true
+    )
+
+    var showMangaUi by prefDelegate(
+        PreferKey.showMangaUi,
+        true
+    )
+
+    var sharedElementEnterTransitionEnable by prefDelegate(
+        PreferKey.sharedElementEnterTransitionEnable,
+        false
+    )
+
+    var delayBookLoadEnable by prefDelegate(
+        PreferKey.delayBookLoadEnable,
+        true
+    )
+
+    private var _userAgent by prefDelegate(
+        PreferKey.userAgent,
+        ""
+    )
+
+    var userAgent: String
+        get() = _userAgent.ifBlank {
+            defaultUserAgent
+        }
+        set(value) {
+            _userAgent = value
+        }
+
+    var webServiceWakeLock by prefDelegate(
+        PreferKey.webServiceWakeLock,
+        false
+    )
+
+    private var _sourceEditMaxLine by prefDelegate(
+        PreferKey.sourceEditMaxLine,
+        Int.MAX_VALUE
+    )
+
+    var sourceEditMaxLine: Int
+        get() = if (_sourceEditMaxLine < 10) Int.MAX_VALUE else _sourceEditMaxLine
+        set(value) {
+            _sourceEditMaxLine = value
+        }
+
+    var cronetEnable by prefDelegate(
+        PreferKey.cronet,
+        false
+    )
+
+    var webPort by prefDelegate(
+        PreferKey.webPort,
+        1122
+    )
+
+    var threadCount by prefDelegate(
+        PreferKey.threadCount,
+        16
+    )
+
+    var processText by prefDelegate(
+        PreferKey.processText,
+        true
+    )
+
+    var recordLog by prefDelegate(
+        PreferKey.recordLog,
+        false
+    )
+
+    var recordHeapDump by prefDelegate(
+        PreferKey.recordHeapDump,
+        false
+    )
+
+    private val defaultUserAgent: String
+        get() = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+                "AppleWebKit/537.36 (KHTML, like Gecko) " +
+                "Chrome/${BuildConfig.Cronet_Main_Version} Safari/537.36"
+
+}

+ 512 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt

@@ -0,0 +1,512 @@
+package io.legado.app.ui.config.otherConfig
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+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.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.service.WebService
+import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.button.SmallTopBarButton
+import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.InputSettingItem
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
+import io.legado.app.utils.restart
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OtherConfigScreen(
+    onBackClick: () -> Unit,
+    viewModel: OtherConfigViewModel = koinViewModel()
+) {
+    val context = LocalContext.current
+
+    val notificationPermissionLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { isGranted ->
+
+    }
+
+    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+    var showClearCacheDialog by remember { mutableStateOf(false) }
+    var showShrinkDbDialog by remember { mutableStateOf(false) }
+    var showClearWebViewDialog by remember { mutableStateOf(false) }
+    var showPasswordDialog by remember { mutableStateOf(false) }
+    var showCheckSourceSheet by remember { mutableStateOf(false) }
+    var showDirectLinkUploadSheet by remember { mutableStateOf(false) }
+
+    var tempPassword by remember { mutableStateOf("") }
+
+    var showFilePicker by remember { mutableStateOf(false) }
+    val selectDocTree = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocumentTree()
+    ) { uri ->
+        uri?.let {
+            val modeFlags =
+                Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+            context.contentResolver.takePersistableUriPermission(it, modeFlags)
+            viewModel.updateLocalBookDir(it.toString())
+        }
+    }
+
+    Scaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = {
+                    Text(stringResource(R.string.other_setting))
+                },
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    SmallTopBarButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(paddingValues)
+                .verticalScroll(rememberScrollState())
+                .padding(16.dp)
+        ) {
+
+            SplicedColumnGroup {
+                DropdownListSettingItem(
+                    title = stringResource(R.string.language),
+                    selectedValue = OtherConfig.language,
+                    displayEntries = stringArrayResource(R.array.language),
+                    entryValues = stringArrayResource(R.array.language_value),
+                    onValueChange = { newValue ->
+                        OtherConfig.language = newValue
+                        context.restart()
+                    }
+                )
+
+                DropdownListSettingItem(
+                    title = stringResource(R.string.update_to_variant_title),
+                    description = stringResource(R.string.update_to_variant_summary),
+                    selectedValue = OtherConfig.updateToVariant,
+                    displayEntries = stringArrayResource(R.array.default_app_variant),
+                    entryValues = stringArrayResource(R.array.default_app_variant_value),
+                    onValueChange = { OtherConfig.updateToVariant = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.web_service_auto_start),
+                    checked = OtherConfig.webServiceAutoStart,
+                    onCheckedChange = { OtherConfig.webServiceAutoStart = it }
+                )
+            }
+
+            SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.pt_auto_refresh),
+                    description = stringResource(R.string.ps_auto_refresh),
+                    checked = OtherConfig.autoRefresh,
+                    onCheckedChange = { OtherConfig.autoRefresh = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.pt_default_read),
+                    description = stringResource(R.string.ps_default_read),
+                    checked = OtherConfig.defaultToRead,
+                    onCheckedChange = { OtherConfig.defaultToRead = it }
+                )
+            }
+
+            SplicedColumnGroup(title = stringResource(R.string.privacy)) {
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.notification_permission),
+                    description = stringResource(R.string.notification_permission_rationale),
+                    onClick = {
+                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                            notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+                        } else {
+                            Toast.makeText(context, "无需申请", Toast.LENGTH_SHORT).show()
+                        }
+                    }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.background_permission),
+                    description = stringResource(R.string.ignore_battery_permission_rationale),
+                    onClick = {
+
+                    }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.firebase_enable_title),
+                    description = stringResource(R.string.firebase_enable_summary),
+                    checked = OtherConfig.firebaseEnable,
+                    onCheckedChange = { OtherConfig.firebaseEnable = it }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.set_local_password),
+                    description = stringResource(R.string.set_local_password_summary),
+                    onClick = { showPasswordDialog = true }
+                )
+
+            }
+
+            SplicedColumnGroup(title = stringResource(R.string.read)) {
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.book_tree_uri_t),
+                    description = OtherConfig.defaultBookTreeUri,
+                    onClick = { showFilePicker = true }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.anti_alias),
+                    description = stringResource(R.string.pref_anti_alias_summary),
+                    checked = OtherConfig.antiAlias,
+                    onCheckedChange = { OtherConfig.antiAlias = it }
+                )
+
+                SliderSettingItem(
+                    title = stringResource(R.string.bitmap_cache_size),
+                    description = stringResource(R.string.bitmap_cache_size_summary),
+                    value = OtherConfig.bitmapCacheSize.toFloat(),
+                    defaultValue = 32f,
+                    valueRange = 1f..2047f,
+                    onValueChange = {
+                        viewModel.updateBitmapCacheSize(it.toInt())
+                    }
+                )
+
+                SliderSettingItem(
+                    title = stringResource(R.string.image_retain_number),
+                    description = stringResource(R.string.image_retain_number_summary),
+                    value = OtherConfig.imageRetainNum.toFloat(),
+                    defaultValue = 10f,
+                    valueRange = 0f..100f,
+                    onValueChange = { OtherConfig.imageRetainNum = it.toInt() }
+                )
+
+                SliderSettingItem(
+                    title = stringResource(R.string.pre_download),
+                    description = stringResource(R.string.pre_download_s),
+                    value = OtherConfig.preDownloadNum.toFloat(),
+                    defaultValue = 10f,
+                    valueRange = 0f..100f,
+                    onValueChange = { OtherConfig.preDownloadNum = it.toInt() }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.replace_enable_default_t),
+                    description = stringResource(R.string.replace_enable_default_s),
+                    checked = OtherConfig.replaceEnableDefault,
+                    onCheckedChange = { OtherConfig.replaceEnableDefault = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.media_button_on_exit_title),
+                    description = stringResource(R.string.media_button_on_exit_summary),
+                    checked = OtherConfig.mediaButtonOnExit,
+                    onCheckedChange = { OtherConfig.mediaButtonOnExit = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.read_aloud_by_media_button_title),
+                    description = stringResource(R.string.read_aloud_by_media_button_summary),
+                    checked = OtherConfig.readAloudByMediaButton,
+                    onCheckedChange = { OtherConfig.readAloudByMediaButton = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.ignore_audio_focus_title),
+                    description = stringResource(R.string.ignore_audio_focus_summary),
+                    checked = OtherConfig.ignoreAudioFocus,
+                    onCheckedChange = { OtherConfig.ignoreAudioFocus = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.auto_clear_expired),
+                    description = stringResource(R.string.auto_clear_expired_summary),
+                    checked = OtherConfig.autoClearExpired,
+                    onCheckedChange = { OtherConfig.autoClearExpired = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.show_add_to_shelf_alert_title),
+                    description = stringResource(R.string.show_add_to_shelf_alert_summary),
+                    checked = OtherConfig.showAddToShelfAlert,
+                    onCheckedChange = { OtherConfig.showAddToShelfAlert = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.show_manga_ui),
+                    checked = OtherConfig.showMangaUi,
+                    onCheckedChange = { OtherConfig.showMangaUi = it }
+                )
+            }
+
+            SplicedColumnGroup(title = stringResource(R.string.other_setting)) {
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.use_animation),
+                    description = stringResource(R.string.opt_animation),
+                    checked = OtherConfig.sharedElementEnterTransitionEnable,
+                    onCheckedChange = { OtherConfig.sharedElementEnterTransitionEnable = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.delay_book_load),
+                    description = stringResource(R.string.reduce_stutter),
+                    checked = OtherConfig.delayBookLoadEnable,
+                    onCheckedChange = { OtherConfig.delayBookLoadEnable = it }
+                )
+
+                InputSettingItem(
+                    title = stringResource(R.string.user_agent),
+                    value = OtherConfig.userAgent,
+                    onConfirm = { viewModel.saveUserAgent(it) }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.web_service_wake_lock),
+                    description = stringResource(R.string.web_service_wake_lock_summary),
+                    checked = OtherConfig.webServiceWakeLock,
+                    onCheckedChange = { OtherConfig.webServiceWakeLock = it }
+                )
+
+                InputSettingItem(
+                    title = stringResource(R.string.source_edit_text_max_line),
+                    value = OtherConfig.sourceEditMaxLine.toString(),
+                    defaultValue = 500.toString(),
+                    onConfirm = { OtherConfig.sourceEditMaxLine = it.toIntOrNull() ?: 500 }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.check_source_config),
+                    onClick = { showCheckSourceSheet = true }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.direct_link_upload_rule),
+                    description = stringResource(R.string.direct_link_upload_rule_summary),
+                    onClick = { showDirectLinkUploadSheet = true }
+                )
+
+                SwitchSettingItem(
+                    title = "Cronet",
+                    description = stringResource(R.string.pref_cronet_summary),
+                    checked = OtherConfig.cronetEnable,
+                    onCheckedChange = { OtherConfig.cronetEnable = it }
+                )
+
+                InputSettingItem(
+                    title = stringResource(R.string.web_port_title),
+                    value = OtherConfig.webPort.toString(),
+                    onConfirm = { newValue ->
+                        OtherConfig.webPort = newValue.toInt()
+                        if (WebService.isRun) {
+                            WebService.stop(context)
+                            WebService.start(context)
+                        }
+                    }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.clear_cache),
+                    description = stringResource(R.string.clear_cache_summary),
+                    onClick = { showClearCacheDialog = true }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.clear_webview_data),
+                    description = stringResource(R.string.clear_webview_data_summary),
+                    onClick = { showClearWebViewDialog = true }
+                )
+
+                ClickableSettingItem(
+                    title = stringResource(R.string.shrink_database),
+                    description = stringResource(R.string.shrink_database_summary),
+                    onClick = { showShrinkDbDialog = true }
+                )
+
+                SliderSettingItem(
+                    title = stringResource(R.string.threads_num_title),
+                    value = OtherConfig.threadCount.toFloat(),
+                    defaultValue = 8f,
+                    valueRange = 1f..256f,
+                    onValueChange = { OtherConfig.threadCount = it.toInt() }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.add_to_text_context_menu_t),
+                    description = stringResource(R.string.add_to_text_context_menu_s),
+                    checked = viewModel.isProcessTextEnabled(),
+                    onCheckedChange = { viewModel.setProcessTextEnable(it) }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.record_log),
+                    description = stringResource(R.string.record_debug_log),
+                    checked = OtherConfig.recordLog,
+                    onCheckedChange = { OtherConfig.recordLog = it }
+                )
+
+                SwitchSettingItem(
+                    title = stringResource(R.string.record_heap_dump_t),
+                    description = stringResource(R.string.record_heap_dump_s),
+                    checked = OtherConfig.recordHeapDump,
+                    onCheckedChange = { OtherConfig.recordHeapDump = it }
+                )
+            }
+        }
+
+        if (showFilePicker) {
+            FilePickerSheet(
+                sheetState = rememberModalBottomSheetState(),
+                onDismissRequest = { showFilePicker = false },
+                onSelectSysDir = {
+                    showFilePicker = false
+                    try {
+                        selectDocTree.launch(null)
+                    } catch (e: Exception) {
+
+                    }
+                }
+            )
+        }
+
+        if (showCheckSourceSheet) {
+            CheckSourceBottomSheet(
+                viewModel = viewModel,
+                onDismiss = { showCheckSourceSheet = false }
+            )
+        }
+
+        if (showDirectLinkUploadSheet) {
+            DirectLinkUploadBottomSheet(
+                viewModel = viewModel,
+                onDismiss = { showDirectLinkUploadSheet = false }
+            )
+        }
+
+        if (showClearCacheDialog) {
+            ConfirmDialog(
+                title = stringResource(R.string.clear_cache),
+                text = stringResource(R.string.sure_del),
+                onConfirm = {
+                    viewModel.clearCache(context)
+                    showClearCacheDialog = false
+                },
+                onDismiss = { showClearCacheDialog = false }
+            )
+        }
+
+        if (showShrinkDbDialog) {
+            ConfirmDialog(
+                title = stringResource(R.string.shrink_database),
+                text = stringResource(R.string.sure),
+                onConfirm = {
+                    viewModel.shrinkDatabase()
+                    showShrinkDbDialog = false
+                },
+                onDismiss = { showShrinkDbDialog = false }
+            )
+        }
+
+        if (showClearWebViewDialog) {
+            ConfirmDialog(
+                title = stringResource(R.string.clear_webview_data),
+                text = stringResource(R.string.sure_del),
+                onConfirm = {
+                    viewModel.clearWebViewData(context)
+                    showClearWebViewDialog = false
+                },
+                onDismiss = { showClearWebViewDialog = false }
+            )
+        }
+
+        if (showPasswordDialog) {
+            AlertDialog(
+                onDismissRequest = { showPasswordDialog = false },
+                title = { Text(stringResource(R.string.set_local_password)) },
+                text = {
+                    TextField(
+                        value = tempPassword,
+                        onValueChange = { tempPassword = it },
+                        label = { Text("Password") }
+                    )
+                },
+                confirmButton = {
+                    TextButton(onClick = {
+                        viewModel.setLocalPassword(tempPassword)
+                        showPasswordDialog = false
+                    }) { Text(stringResource(R.string.ok)) }
+                },
+                dismissButton = {
+                    TextButton(onClick = { showPasswordDialog = false }) {
+                        Text(stringResource(R.string.cancel))
+                    }
+                }
+            )
+        }
+    }
+
+}
+
+@Composable
+fun ConfirmDialog(
+    title: String,
+    text: String,
+    onConfirm: () -> Unit,
+    onDismiss: () -> Unit
+) {
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text(title) },
+        text = { Text(text) },
+        confirmButton = {
+            OutlinedButton(onClick = onConfirm) {
+                Text(stringResource(R.string.ok))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text(stringResource(R.string.cancel))
+            }
+        }
+    )
+}

+ 157 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -0,0 +1,157 @@
+package io.legado.app.ui.config.otherConfig
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.appDb
+import io.legado.app.help.DirectLinkUpload
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.LocalConfig
+import io.legado.app.model.CheckSource
+import io.legado.app.model.ImageProvider
+import io.legado.app.receiver.SharedReceiverActivity
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.putPrefString
+import io.legado.app.utils.restart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import splitties.init.appCtx
+
+class OtherConfigViewModel : ViewModel() {
+
+    private val packageManager = appCtx.packageManager
+    private val componentName = ComponentName(
+        appCtx,
+        SharedReceiverActivity::class.java.name
+    )
+
+    fun isProcessTextEnabled(): Boolean {
+        return packageManager.getComponentEnabledSetting(componentName) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+    }
+
+    fun setProcessTextEnable(enable: Boolean) {
+        val state = if (enable) {
+            PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+        } else {
+            PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+        }
+        packageManager.setComponentEnabledSetting(
+            componentName,
+            state,
+            PackageManager.DONT_KILL_APP
+        )
+    }
+
+    fun clearCache(context: Context) {
+        viewModelScope.launch(Dispatchers.IO) {
+            BookHelp.clearCache()
+            FileUtils.delete(context.cacheDir.absolutePath)
+            context.externalCacheDir?.deleteRecursively()
+            context.cacheDir.deleteRecursively()
+        }
+    }
+
+    fun shrinkDatabase() {
+        viewModelScope.launch(Dispatchers.IO) {
+            appDb.openHelper.writableDatabase.execSQL("VACUUM")
+        }
+    }
+
+    fun clearWebViewData(context: Context) {
+        viewModelScope.launch(Dispatchers.IO) {
+            FileUtils.delete(context.getDir("webview", Context.MODE_PRIVATE))
+            FileUtils.delete(context.getDir("hws_webview", Context.MODE_PRIVATE), true)
+            delay(3000)
+            appCtx.restart()
+        }
+    }
+
+    fun setLocalPassword(password: String) {
+        LocalConfig.password = password
+    }
+
+    fun saveUserAgent(input: String) {
+        OtherConfig.userAgent = input
+        AppConfig.userAgent = OtherConfig.userAgent
+    }
+
+    fun updateBitmapCacheSize(size: Int) {
+        AppConfig.bitmapCacheSize = size
+        ImageProvider.bitmapLruCache.resize(ImageProvider.cacheSize)
+    }
+
+    fun updateLocalBookDir(path: String) {
+        OtherConfig.defaultBookTreeUri = path
+        AppConfig.defaultBookTreeUri = OtherConfig.defaultBookTreeUri
+    }
+
+    var checkSourceTimeout by mutableStateOf((CheckSource.timeout / 1000))
+    var checkSearch by mutableStateOf(CheckSource.checkSearch)
+    var checkDiscovery by mutableStateOf(CheckSource.checkDiscovery)
+    var checkInfo by mutableStateOf(CheckSource.checkInfo)
+    var checkCategory by mutableStateOf(CheckSource.checkCategory)
+    var checkContent by mutableStateOf(CheckSource.checkContent)
+
+    fun saveCheckSourceConfig(): Boolean {
+        val timeoutLong = checkSourceTimeout
+        if (timeoutLong <= 0) return false // 验证失败
+
+        CheckSource.timeout = timeoutLong * 1000
+        CheckSource.checkSearch = checkSearch
+        CheckSource.checkDiscovery = checkDiscovery
+        CheckSource.checkInfo = checkInfo
+        CheckSource.checkCategory = checkCategory
+        CheckSource.checkContent = checkContent
+
+        CheckSource.putConfig()
+        appCtx.putPrefString(PreferKey.checkSource, CheckSource.summary)
+        return true
+    }
+
+    var uploadUrl by mutableStateOf("")
+    var downloadUrlRule by mutableStateOf("")
+    var summary by mutableStateOf("")
+    var compress by mutableStateOf(false)
+
+    fun initDirectLinkRule() {
+        val rule = DirectLinkUpload.getRule()
+        upView(rule)
+    }
+
+    fun upView(rule: DirectLinkUpload.Rule) {
+        uploadUrl = rule.uploadUrl
+        downloadUrlRule = rule.downloadUrlRule
+        summary = rule.summary
+        compress = rule.compress
+    }
+
+    fun saveDirectLinkRule(): Boolean {
+        if (uploadUrl.isBlank() || downloadUrlRule.isBlank() || summary.isBlank()) return false
+        val rule = DirectLinkUpload.Rule(uploadUrl, downloadUrlRule, summary, compress)
+        DirectLinkUpload.putConfig(rule)
+        return true
+    }
+
+    fun testRule(onResult: (String) -> Unit) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val rule = DirectLinkUpload.Rule(uploadUrl, downloadUrlRule, summary, compress)
+            runCatching {
+                DirectLinkUpload.upLoad("test.json", "{}", "application/json", rule)
+            }.onSuccess {
+                onResult(it)
+            }.onFailure {
+                onResult(it.localizedMessage ?: "ERROR")
+            }
+        }
+    }
+
+
+}

+ 0 - 2
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -154,12 +154,10 @@ fun DictRuleScreen(
         FilePickerSheet(
             sheetState = sheetState,
             onDismissRequest = { showFilePickerSheet = false },
-            mode = filePickerMode,
             onSelectSysDir = {
                 showFilePickerSheet = false
                 exportDoc.launch("exportDictRule.json")
             },
-            onSelectSysFile = {},
             onUpload = {
                 showFilePickerSheet = false
                 viewModel.uploadSelectedRules(selectedIds, rules)

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt

@@ -52,8 +52,8 @@ import io.legado.app.ui.dict.rule.DictRuleActivity
 import io.legado.app.ui.file.FileManageActivity
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
-import io.legado.app.ui.widget.components.SettingItem
 import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.settingItem.SettingItem
 
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)

+ 0 - 2
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -167,12 +167,10 @@ fun ReplaceRuleScreen(
         FilePickerSheet(
             sheetState = sheetState,
             onDismissRequest = { showFilePickerSheet = false },
-            mode = filePickerMode,
             onSelectSysDir = {
                 showFilePickerSheet = false
                 exportDoc.launch("exportReplaceRule.json")
             },
-            onSelectSysFile = {},
             onUpload = {
                 showFilePickerSheet = false
                 viewModel.uploadSelectedRules(selectedIds, rules)

+ 0 - 182
app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt

@@ -1,182 +0,0 @@
-package io.legado.app.ui.widget.components
-
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material.icons.filled.Info
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.MaterialTheme
-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.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.widget.components.card.GlassCard
-
-@Preview(showBackground = true)
-@Composable
-fun PreviewSettingItemList() {
-    MaterialTheme {
-        Column(modifier = Modifier.padding(16.dp)) {
-
-            SettingItem(
-                title = "Only Title",
-                description = null,
-                option = null,
-                onClick = {}
-            )
-
-            SettingItem(
-                title = "Title + Description",
-                description = "This is a description",
-                option = null,
-                onClick = {}
-            )
-
-            SettingItem(
-                title = "Title + Option",
-                description = null,
-                option = "Dynamic option text",
-                onClick = {}
-            )
-
-            SettingItem(
-                title = "Title + Desc + Option",
-                description = "Description content here",
-                option = "Animated changing string",
-                onClick = {}
-            )
-
-            SettingItem(
-                imageVector = Icons.Default.Info,
-                title = "With Icon",
-                description = "Description",
-                option = "Option",
-                onClick = {}
-            )
-
-            SettingItem(
-                title = "With Trailing",
-                description = "Trailing icon on the right",
-                option = null,
-                trailingContent = {
-                    Icon(
-                        imageVector = Icons.Default.ChevronRight,
-                        contentDescription = null
-                    )
-                },
-                onClick = {}
-            )
-
-        }
-    }
-}
-
-@Composable
-fun SettingItem(
-    modifier: Modifier = Modifier,
-    painter: Painter? = null,
-    imageVector: ImageVector? = null,
-    title: String,
-    description: String? = null,
-    option: String? = null,
-    trailingContent: @Composable (() -> Unit)? = null,
-    dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
-    onClick: () -> Unit,
-    onLongClick: (() -> Unit)? = null,
-) {
-    var showMenu by remember { mutableStateOf(false) }
-
-    GlassCard(
-        modifier = Modifier
-            .fillMaxWidth()
-            .animateContentSize(),
-        shape = RoundedCornerShape(8.dp)
-    ) {
-        ListItem(
-            modifier = modifier
-                .combinedClickable(
-                    onClick = onClick,
-                    onLongClick = {
-                        if (dropdownMenu != null) {
-                            showMenu = true
-                            onLongClick?.invoke()
-                        }
-                    }
-                ),
-            leadingContent = {
-                when {
-                    painter != null -> Icon(
-                        painter = painter,
-                        contentDescription = null,
-                        tint = MaterialTheme.colorScheme.onSurfaceVariant
-                    )
-
-                    imageVector != null -> Icon(
-                        imageVector = imageVector,
-                        contentDescription = null,
-                        tint = MaterialTheme.colorScheme.onSurfaceVariant
-                    )
-                }
-            },
-            headlineContent = {
-                Text(
-                    text = title,
-                    style = MaterialTheme.typography.titleMedium
-                )
-            },
-            supportingContent = {
-                Column(
-                    verticalArrangement = Arrangement.spacedBy(2.dp)
-                ) {
-                    description?.let {
-                        Text(
-                            text = it,
-                            style = MaterialTheme.typography.bodySmall,
-                            color = MaterialTheme.colorScheme.onSurfaceVariant
-                        )
-                    }
-                    option?.let {
-                        Text(
-                            text = it,
-                            style = MaterialTheme.typography.labelMedium,
-                            color = MaterialTheme.colorScheme.primary,
-                            maxLines = 3,
-                            overflow = TextOverflow.Ellipsis
-                        )
-                    }
-                }
-            },
-            trailingContent = trailingContent,
-            colors = ListItemDefaults.colors(containerColor = Color.Transparent)
-        )
-
-        if (dropdownMenu != null) {
-            DropdownMenu(
-                expanded = showMenu,
-                onDismissRequest = { showMenu = false }
-            ) {
-                dropdownMenu {
-                    showMenu = false
-                }
-            }
-        }
-    }
-}

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -26,13 +26,13 @@ fun SplicedColumnGroup(
     title: String = "",
     content: @Composable ColumnScope.() -> Unit,
 ) {
-    Column(modifier = modifier.padding(vertical = 8.dp)) {
+    Column(modifier = modifier.padding(top = 4.dp, bottom = 12.dp)) {
         if (title.isNotEmpty()) {
             Text(
                 text = title,
                 style = MaterialTheme.typography.labelLarge,
                 color = MaterialTheme.colorScheme.primary,
-                modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
+                modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
             )
         }
 

+ 40 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.widget.components.button
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SmallTextButton(
+    text: String,
+    icon: ImageVector,
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit
+) {
+    TextButton(
+        onClick = onClick,
+        modifier = modifier,
+        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
+        shape = MaterialTheme.shapes.small
+    ) {
+        Icon(
+            imageVector = icon,
+            contentDescription = null,
+            modifier = Modifier.size(16.dp)
+        )
+        Spacer(Modifier.width(4.dp))
+        Text(
+            text = text,
+            style = MaterialTheme.typography.labelMedium
+        )
+    }
+}

+ 27 - 0
app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxGroupContainer.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.widget.components.checkBox
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CheckboxGroupContainer(
+    columns: Int = 3,
+    content: LazyGridScope.() -> Unit
+) {
+    LazyVerticalGrid(
+        columns = GridCells.Fixed(columns),
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        verticalArrangement = Arrangement.spacedBy(4.dp),
+        content = content
+    )
+}

+ 57 - 0
app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt

@@ -0,0 +1,57 @@
+package io.legado.app.ui.widget.components.checkBox
+
+import androidx.compose.foundation.background
+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.material3.Checkbox
+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.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CheckboxItem(
+    title: String,
+    color: Color = Color.Transparent,
+    checked: Boolean,
+    enabled: Boolean = true,
+    onCheckedChange: (Boolean) -> Unit
+) {
+    val alpha = if (enabled) 1f else 0.5f
+
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(MaterialTheme.shapes.small)
+            .background(
+                color = if (checked && enabled) MaterialTheme.colorScheme.secondaryContainer
+                else color,
+                shape = MaterialTheme.shapes.small
+            )
+            .clickable(enabled = enabled) { onCheckedChange(!checked) }
+            .padding(vertical = 12.dp, horizontal = 12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Checkbox(
+            checked = checked,
+            onCheckedChange = null,
+            enabled = enabled,
+            modifier = Modifier.alpha(alpha)
+        )
+        Text(
+            text = title,
+            style = MaterialTheme.typography.bodyMedium,
+            maxLines = 1,
+            modifier = Modifier
+                .padding(start = 12.dp)
+                .alpha(alpha)
+        )
+    }
+}

+ 44 - 41
app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt

@@ -3,7 +3,7 @@ package io.legado.app.ui.widget.components.exportComponents
 import android.webkit.MimeTypeMap
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
@@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
 import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.EditNote
 import androidx.compose.material.icons.filled.FolderOpen
-import androidx.compose.material.icons.filled.SaveAlt
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
@@ -40,9 +40,10 @@ enum class FilePickerSheetMode {
 fun FilePickerSheet(
     sheetState: SheetState,
     onDismissRequest: () -> Unit,
-    mode: FilePickerSheetMode,
-    onSelectSysDir: () -> Unit,
-    onSelectSysFile: (Array<String>) -> Unit,
+    title: String = stringResource(R.string.select_operation),
+    onSelectSysDir: (() -> Unit)? = null,
+    onSelectSysFile: ((Array<String>) -> Unit)? = null,
+    onManualInput: (() -> Unit)? = null,
     onUpload: (() -> Unit)? = null,
     allowExtensions: Array<String>? = null,
 ) {
@@ -54,53 +55,55 @@ fun FilePickerSheet(
         Column(
             modifier = Modifier
                 .fillMaxWidth()
-                .padding(horizontal = 16.dp)
-                .padding(bottom = 16.dp),
+                .padding(horizontal = 16.dp, vertical = 8.dp),
             horizontalAlignment = Alignment.CenterHorizontally
         ) {
-
             Text(
-                modifier = Modifier.padding(bottom = 16.dp),
-                text = stringResource(R.string.select_operation),
+                text = title,
                 style = MaterialTheme.typography.titleMedium,
+                modifier = Modifier.padding(bottom = 20.dp)
             )
 
-            Row(
+            FlowRow(
                 modifier = Modifier.fillMaxWidth(),
-                horizontalArrangement = Arrangement.spacedBy(16.dp)
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+                maxItemsInEachRow = 2
             ) {
-                when (mode) {
-                    FilePickerSheetMode.DIR -> {
-                        FilePickerOptionCard(
-                            icon = Icons.Default.FolderOpen,
-                            text = stringResource(R.string.sys_folder_picker),
-                            onClick = onSelectSysDir
-                        )
-                    }
-                    FilePickerSheetMode.FILE -> {
-                        FilePickerOptionCard(
-                            icon = Icons.AutoMirrored.Filled.InsertDriveFile,
-                            text = stringResource(R.string.sys_file_picker),
-                            onClick = { onSelectSysFile(typesOfExtensions(allowExtensions)) }
-                        )
-                    }
-                    FilePickerSheetMode.EXPORT -> {
-                        FilePickerOptionCard(
-                            icon = Icons.Default.SaveAlt,
-                            text = stringResource(R.string.save_to_local),
-                            onClick = onSelectSysDir
-                        )
 
-                        if (onUpload != null) {
-                            FilePickerOptionCard(
-                                icon = Icons.Default.CloudUpload,
-                                text = stringResource(R.string.upload_url),
-                                onClick = onUpload
-                            )
-                        }
-                    }
+                onSelectSysDir?.let {
+                    FilePickerOptionCard(
+                        icon = Icons.Default.FolderOpen,
+                        text = stringResource(R.string.sys_folder_picker),
+                        onClick = it
+                    )
+                }
+
+                onSelectSysFile?.let {
+                    FilePickerOptionCard(
+                        icon = Icons.AutoMirrored.Filled.InsertDriveFile,
+                        text = stringResource(R.string.sys_file_picker),
+                        onClick = { it(typesOfExtensions(allowExtensions)) }
+                    )
+                }
+
+                onManualInput?.let {
+                    FilePickerOptionCard(
+                        icon = Icons.Default.EditNote,
+                        text = stringResource(R.string.manual_input),
+                        onClick = it
+                    )
+                }
+
+                onUpload?.let {
+                    FilePickerOptionCard(
+                        icon = Icons.Default.CloudUpload,
+                        text = stringResource(R.string.upload_url),
+                        onClick = it
+                    )
                 }
             }
+            Spacer(modifier = Modifier.height(24.dp))
         }
     }
 }

+ 35 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt

@@ -0,0 +1,35 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+
+@Composable
+fun ClickableSettingItem(
+    title: String,
+    modifier: Modifier = Modifier,
+    description: String? = null,
+    option: String? = null,
+    imageVector: ImageVector? = null,
+    onClick: () -> Unit
+) {
+    SettingItem(
+        modifier = modifier,
+        title = title,
+        description = description,
+        option = option,
+        imageVector = imageVector,
+        trailingContent = {
+            Icon(
+                imageVector = Icons.Default.ChevronRight,
+                contentDescription = null,
+                tint = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+        },
+        onClick = onClick
+    )
+}

+ 92 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt

@@ -0,0 +1,92 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Replay
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+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.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.button.SmallTextButton
+
+@Composable
+fun InputSettingItem(
+    title: String,
+    value: String,
+    defaultValue: String? = "",
+    description: String? = null,
+    onConfirm: (String) -> Unit
+) {
+    var expanded by remember { mutableStateOf(false) }
+    val state = rememberTextFieldState(initialText = value)
+
+    LaunchedEffect(expanded) {
+        if (expanded) {
+            state.edit {
+                replace(0, length, value)
+            }
+        }
+    }
+
+    SettingItem(
+        title = title,
+        description = description,
+        option = value,
+        expanded = expanded,
+        onExpandChange = { expanded = it },
+        expandContent = {
+            TextField(
+                state = state,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(min = 48.dp),
+                label = { Text(stringResource(R.string.edit)) },
+                contentPadding = PaddingValues(
+                    top = 4.dp,
+                    bottom = 4.dp,
+                    start = 12.dp,
+                    end = 12.dp
+                ),
+                onKeyboardAction = {
+                    onConfirm(state.text.toString())
+                    expanded = false
+                }
+            )
+
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.End
+            ) {
+                SmallTextButton(
+                    text = "默认",
+                    icon = Icons.Default.Replay,
+                    onClick = {
+                        state.edit { replace(0, length, defaultValue.toString()) }
+                    }
+                )
+                SmallTextButton(
+                    text = "确认",
+                    icon = Icons.Default.Check,
+                    onClick = {
+                        onConfirm(state.text.toString())
+                        expanded = false
+                    }
+                )
+            }
+        }
+    )
+}

+ 53 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt

@@ -0,0 +1,53 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun DropdownListSettingItem(
+    title: String,
+    selectedValue: String,
+    displayEntries: Array<String>,
+    entryValues: Array<String>,
+    description: String? = null,
+    imageVector: ImageVector? = null,
+    onValueChange: (String) -> Unit
+) {
+    val currentEntry = displayEntries.getOrNull(entryValues.indexOf(selectedValue)) ?: selectedValue
+
+    SettingItem(
+        title = title,
+        description = description,
+        option = currentEntry,
+        imageVector = imageVector,
+        onClick = { },
+        dropdownMenu = { onDismiss ->
+            displayEntries.forEachIndexed { index, display ->
+                DropdownMenuItem(
+                    text = { Text(display) },
+                    onClick = {
+                        onValueChange(entryValues[index])
+                        onDismiss()
+                    },
+                    trailingIcon = if (selectedValue == entryValues[index]) {
+                        {
+                            Icon(
+                                Icons.Default.Check,
+                                contentDescription = null,
+                                modifier = Modifier.size(18.dp)
+                            )
+                        }
+                    } else null
+                )
+            }
+        }
+    )
+}

+ 170 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/SettingItem.kt

@@ -0,0 +1,170 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+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.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.card.GlassCard
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SettingItem(
+    modifier: Modifier = Modifier,
+    painter: Painter? = null,
+    imageVector: ImageVector? = null,
+    color: Color? = null,
+    title: String,
+    description: String? = null,
+    option: String? = null,
+    trailingContent: @Composable (() -> Unit)? = null,
+    dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
+    onClick: (() -> Unit)? = null,
+    onLongClick: (() -> Unit)? = null,
+    expanded: Boolean = false,
+    onExpandChange: ((Boolean) -> Unit)? = null,
+    expandContent: (@Composable ColumnScope.() -> Unit)? = null
+) {
+
+    var showMenu by remember { mutableStateOf(false) }
+    val isExpandable = expandContent != null && onExpandChange != null
+
+    GlassCard(
+        modifier = Modifier
+            .fillMaxWidth(),
+        shape = RoundedCornerShape(4.dp),
+        color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column {
+            ListItem(
+                modifier = modifier
+                    .combinedClickable(
+                        onClick = {
+                            when {
+                                dropdownMenu != null -> showMenu = true
+                                isExpandable -> onExpandChange.invoke(!expanded)
+                                else -> onClick?.invoke()
+                            }
+                        },
+                        onLongClick = {
+                            if (dropdownMenu != null) showMenu = true
+                            onLongClick?.invoke()
+                        }
+                    ),
+                leadingContent = if (painter != null || imageVector != null) {
+                    {
+                        if (painter != null) {
+                            Icon(
+                                painter = painter,
+                                contentDescription = null,
+                                tint = MaterialTheme.colorScheme.onSurfaceVariant
+                            )
+                        } else if (imageVector != null) {
+                            Icon(
+                                imageVector = imageVector,
+                                contentDescription = null,
+                                tint = MaterialTheme.colorScheme.onSurfaceVariant
+                            )
+                        }
+                    }
+                } else null,
+                headlineContent = {
+                    Text(
+                        text = title,
+                        style = MaterialTheme.typography.titleMedium
+                    )
+                },
+                supportingContent = if (description != null || option != null) {
+                    {
+                        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+                            description?.let {
+                                Text(
+                                    it,
+                                    style = MaterialTheme.typography.bodySmallEmphasized,
+                                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+                                )
+                            }
+                            option?.let {
+                                Text(
+                                    it,
+                                    style = MaterialTheme.typography.labelMediumEmphasized,
+                                    color = MaterialTheme.colorScheme.primary
+                                )
+                            }
+                        }
+                    }
+                } else null,
+                trailingContent = {
+                    Box(contentAlignment = Alignment.Center) {
+                        if (isExpandable && trailingContent == null) {
+                            val rotation by animateFloatAsState(
+                                if (expanded) 180f else 0f,
+                                label = "arrow"
+                            )
+                            Icon(
+                                imageVector = Icons.Default.KeyboardArrowDown,
+                                contentDescription = null,
+                                modifier = Modifier.rotate(rotation)
+                            )
+                        } else {
+                            trailingContent?.invoke()
+                        }
+
+                        dropdownMenu?.let { menu ->
+                            DropdownMenu(
+                                expanded = showMenu,
+                                onDismissRequest = { showMenu = false }) {
+                                menu { showMenu = false }
+                            }
+                        }
+                    }
+                },
+                colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+            )
+
+            if (isExpandable) {
+                AnimatedVisibility(
+                    visible = expanded,
+                    enter = expandVertically(expandFrom = Alignment.Top),
+                    exit = shrinkVertically(shrinkTowards = Alignment.Top)
+                ) {
+                    Column(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(start = 16.dp, end = 16.dp, bottom = 12.dp, top = 8.dp)
+                    ) {
+                        expandContent.invoke(this)
+                    }
+                }
+            }
+        }
+    }
+}

+ 130 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt

@@ -0,0 +1,130 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.LinearScale
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+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.graphics.Color
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.button.SmallTextButton
+
+@Composable
+fun SliderSettingItem(
+    title: String,
+    color: Color? = null,
+    value: Float,
+    defaultValue: Float,
+    valueRange: ClosedFloatingPointRange<Float>,
+    steps: Int = 0,
+    description: String? = null,
+    onValueChange: (Float) -> Unit
+) {
+
+    var expanded by remember { mutableStateOf(false) }
+    var isInputMode by remember { mutableStateOf(false) }
+    var inputText by remember { mutableStateOf(value.toInt().toString()) }
+    val textFieldState = rememberTextFieldState(initialText = value.toInt().toString())
+
+    SettingItem(
+        title = title,
+        description = description,
+        color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
+        option = value.toInt().toString(),
+        expanded = expanded,
+        onExpandChange = { expanded = it },
+        expandContent = {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                AnimatedContent(
+                    targetState = isInputMode,
+                    label = "input_slider_switch"
+                ) { targetInputMode ->
+                    if (targetInputMode) {
+                        TextField(
+                            state = textFieldState,
+                            lineLimits = TextFieldLineLimits.SingleLine,
+                            label = { Text("输入数值 (${valueRange.start.toInt()}-${valueRange.endInclusive.toInt()})") },
+                            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .heightIn(min = 48.dp),
+                            contentPadding = PaddingValues(
+                                top = 4.dp,
+                                bottom = 4.dp,
+                                start = 12.dp,
+                                end = 12.dp
+                            ),
+                            inputTransformation = {
+                                val newText = asCharSequence().toString()
+                                newText.toFloatOrNull()?.let { num ->
+                                    onValueChange(
+                                        num.coerceIn(
+                                            valueRange.start,
+                                            valueRange.endInclusive
+                                        )
+                                    )
+                                }
+                            }
+                        )
+                    } else {
+                        Slider(
+                            value = value,
+                            onValueChange = {
+                                onValueChange(it)
+                                textFieldState.edit { replace(0, length, it.toInt().toString()) }
+                            },
+                            valueRange = valueRange,
+                            steps = steps,
+                            modifier = Modifier
+                                .fillMaxWidth()
+                        )
+                    }
+                }
+            }
+
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.End
+            ) {
+                SmallTextButton(
+                    text = if (isInputMode) "滑块" else "输入",
+                    icon = if (isInputMode) Icons.Default.LinearScale else Icons.Default.Edit,
+                    onClick = { isInputMode = !isInputMode }
+                )
+
+                Spacer(Modifier.width(8.dp))
+
+                SmallTextButton(
+                    text = "默认",
+                    icon = Icons.Default.RestartAlt,
+                    onClick = {
+                        onValueChange(defaultValue)
+                        textFieldState.edit { replace(0, length, defaultValue.toInt().toString()) }
+                    }
+                )
+            }
+        }
+    )
+}

+ 27 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.widget.components.settingItem
+
+import androidx.compose.material3.Switch
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+
+@Composable
+fun SwitchSettingItem(
+    title: String,
+    description: String? = null,
+    checked: Boolean,
+    imageVector: ImageVector? = null,
+    onCheckedChange: (Boolean) -> Unit
+) {
+    SettingItem(
+        title = title,
+        description = description,
+        imageVector = imageVector,
+        onClick = { onCheckedChange(!checked) },
+        trailingContent = {
+            Switch(
+                checked = checked,
+                onCheckedChange = onCheckedChange
+            )
+        }
+    )
+}

+ 4 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -225,7 +225,7 @@
     <string name="no_download">暂无任务</string>
     <string name="download_count">%1$d/%2$d</string>
     <string name="import_select_book">导入选择书籍</string>
-    <string name="threads_num_title">更新和搜索线程数(太多会卡顿)</string>
+    <string name="threads_num_title">更新和搜索线程数</string>
     <string name="change_icon">切换图标</string>
     <string name="remove_from_bookshelf">已在书架</string>
     <string name="start_read">开始阅读</string>
@@ -890,7 +890,7 @@
     <string name="anti_alias">抗锯齿</string>
     <string name="pref_anti_alias_summary">绘制图片时抗锯齿</string>
     <string name="upload_url">上传 URL</string>
-    <string name="download_url_rule">下载URL规则(downloadUrls)</string>
+    <string name="download_url_rule">下载 URL 规则</string>
     <string name="sort_by_respondTime">响应时间排序</string>
     <string name="respondTime">响应时间:%1$d ms</string>
     <string name="export_success">导出成功</string>
@@ -1080,7 +1080,7 @@
     <string name="tip_divider_color">分隔线颜色</string>
     <string name="same_title_removed">移除重复标题</string>
     <string name="update_book_fail">更新失败</string>
-    <string name="notification_permission_rationale">阅读需要发送通知来显示朗读控制和下载进度,拒绝后将不再弹出。如有需要可至高级设置中再次申请。</string>
+    <string name="notification_permission_rationale">阅读需要发送通知来显示朗读控制和下载进度。</string>
     <string name="webdav_after_local_restore_confirm">WebDav 书源比本地新,是否恢复</string>
     <string name="c_whitelist">白名单(contentWhitelist)</string>
     <string name="c_blacklist">黑名单(contentBlacklist)</string>
@@ -1383,4 +1383,5 @@
     <string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
     <string name="show_swipe_animation">启用滑动时动画</string>
     <string name="click_image_way">点击图片行为</string>
+    <string name="show_web_log">输出日志</string>
 </resources>

+ 5 - 0
app/src/main/res/xml/pref_config_other.xml

@@ -2,6 +2,11 @@
 <androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
 
+    <io.legado.app.lib.prefs.Preference
+        android:key="TEST"
+        android:summary="这里存放由 Compose 重写的设置页"
+        android:title="测试" />
+
     <io.legado.app.lib.prefs.NameListPreference
         android:defaultValue="auto"
         android:key="language"