[优化] 新增了新的外观设置、备份与恢复设置,现在可以在高级、测试处体验。

[修复] 封面状态不同步的问题、文件夹封面和书籍封面获取的问题、容器透明度不生效的问题等
This commit is contained in:
HapeLee
2026-03-23 01:36:31 +08:00
parent 1e41ce5ded
commit 36649527ed
25 changed files with 2005 additions and 353 deletions
@@ -28,6 +28,7 @@ import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
@@ -94,6 +95,7 @@ val appModule = module {
viewModelOf(::ReadConfigViewModel)
viewModelOf(::CoverConfigViewModel)
viewModelOf(::ThemeConfigViewModel)
viewModelOf(::BackupConfigViewModel)
viewModelOf(::TocViewModel)
viewModelOf(::RemoteBookViewModel)
viewModelOf(::BookInfoViewModel)
@@ -3,7 +3,6 @@ package io.legado.app.model
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import androidx.annotation.Keep
import androidx.core.graphics.drawable.toDrawable
@@ -35,8 +34,6 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.BitmapUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefInt
import io.legado.app.utils.getPrefString
import kotlinx.coroutines.currentCoroutineContext
import splitties.init.appCtx
@@ -48,76 +45,32 @@ object BookCover {
private const val coverRuleConfigKey = "legadoCoverRuleConfig"
const val configFileName = "coverRule.json"
var drawBookName = true
private set
var drawBookAuthor = true
private set
var drawBookShadow = true
private set
var drawBookStroke = true
private set
var coverTextColor: Int = Color.WHITE
private set
var coverShadowColor: Int = Color.WHITE
private set
var coverTextColorN: Int = Color.WHITE
private set
var coverShadowColorN: Int = Color.WHITE
private set
var coverDefaultColor = true
private set
lateinit var defaultDrawable: Drawable
private set
init {
upDefaultCover()
}
val defaultDrawable: Drawable
@SuppressLint("UseCompatLoadingForDrawables")
get() {
val isNightTheme = AppConfig.isNightTheme
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
@SuppressLint("UseCompatLoadingForDrawables")
fun upDefaultCover() {
val isNightTheme = AppConfig.isNightTheme
drawBookName = if (isNightTheme) {
appCtx.getPrefBoolean(PreferKey.coverShowNameN, true)
} else {
appCtx.getPrefBoolean(PreferKey.coverShowName, true)
}
drawBookAuthor = if (isNightTheme) {
appCtx.getPrefBoolean(PreferKey.coverShowAuthorN, true)
} else {
appCtx.getPrefBoolean(PreferKey.coverShowAuthor, true)
}
drawBookShadow =
appCtx.getPrefBoolean(PreferKey.coverShowShadow, false)
drawBookStroke =
appCtx.getPrefBoolean(PreferKey.coverShowStroke, true)
coverDefaultColor =
appCtx.getPrefBoolean(PreferKey.coverDefaultColor, true)
coverTextColor =
appCtx.getPrefInt(PreferKey.coverTextColor, Color.WHITE)
coverTextColorN =
appCtx.getPrefInt(PreferKey.coverTextColorN, Color.BLACK)
coverShadowColor =
appCtx.getPrefInt(PreferKey.coverShadowColor, Color.WHITE)
coverShadowColorN =
appCtx.getPrefInt(PreferKey.coverShadowColorN, Color.BLACK)
if (paths.isNullOrEmpty()) {
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
}
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
if (paths.isNullOrEmpty()) {
defaultDrawable = appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
return
val randomPath = paths[Random.nextInt(paths.size)]
return kotlin.runCatching {
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
val randomPath = paths[Random.nextInt(paths.size)]
defaultDrawable = kotlin.runCatching {
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
// 兼容旧代码,空实现
fun upDefaultCover() {}
fun getRandomDefaultPath(seed: Any? = null): String? {
val isNightTheme = AppConfig.isNightTheme
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
fun getRandomDefaultPath(
seed: Any? = null,
isNight: Boolean = AppConfig.isNightTheme
): String? {
val key = if (isNight) PreferKey.defaultCoverDark else PreferKey.defaultCover
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
if (paths.isNullOrEmpty()) return null
val random = if (seed != null) Random(seed.hashCode()) else Random
@@ -74,7 +74,7 @@ fun <T> prefDelegate(
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
if (_value.value != value) {
when (value) {
is String -> appCtx.putPrefString(key, 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)
@@ -17,6 +17,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import io.legado.app.R
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.config.backupConfig.BackupConfigScreen
import io.legado.app.ui.config.coverConfig.CoverConfigScreen
import io.legado.app.ui.config.otherConfig.OtherConfigScreen
import io.legado.app.ui.config.readConfig.ReadConfigScreen
@@ -46,6 +47,9 @@ class TestConfigActivity : BaseComposeActivity() {
@Serializable
object ThemeConfigRoute
@Serializable
object BackupConfigRoute
@Composable
override fun Content() {
val navController = rememberNavController()
@@ -60,7 +64,8 @@ class TestConfigActivity : BaseComposeActivity() {
onNavigateToOther = { navController.navigate(OtherConfigRoute) },
onNavigateToRead = { navController.navigate(ReadConfigRoute) },
onNavigateToCover = { navController.navigate(CoverConfigRoute) },
onNavigateToTheme = { navController.navigate(ThemeConfigRoute) }
onNavigateToTheme = { navController.navigate(ThemeConfigRoute) },
onNavigateToBackup = { navController.navigate(BackupConfigRoute) }
)
}
@@ -77,7 +82,13 @@ class TestConfigActivity : BaseComposeActivity() {
}
composable<ThemeConfigRoute> {
ThemeConfigScreen(onBackClick = { navController.popBackStack() })
ThemeConfigScreen(
onBackClick = { navController.popBackStack() }
)
}
composable<BackupConfigRoute> {
BackupConfigScreen(onBackClick = { navController.popBackStack() })
}
}
}
@@ -89,7 +100,8 @@ class TestConfigActivity : BaseComposeActivity() {
onNavigateToOther: () -> Unit,
onNavigateToRead: () -> Unit,
onNavigateToCover: () -> Unit,
onNavigateToTheme: () -> Unit
onNavigateToTheme: () -> Unit,
onNavigateToBackup: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -129,6 +141,10 @@ class TestConfigActivity : BaseComposeActivity() {
title = stringResource(R.string.cover_config),
onClick = onNavigateToCover
)
ClickableSettingItem(
title = stringResource(R.string.backup_restore),
onClick = onNavigateToBackup
)
}
}
}
@@ -0,0 +1,58 @@
package io.legado.app.ui.config.backupConfig
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
object BackupConfig {
var webDavUrl by prefDelegate(
PreferKey.webDavUrl,
""
)
var webDavAccount by prefDelegate(
PreferKey.webDavAccount,
""
)
var webDavPassword by prefDelegate(
PreferKey.webDavPassword,
""
)
var webDavDir by prefDelegate(
PreferKey.webDavDir,
"legado"
)
var webDavDeviceName by prefDelegate(
PreferKey.webDavDeviceName,
""
)
var syncBookProgress by prefDelegate(
PreferKey.syncBookProgress,
true
)
var syncBookProgressPlus by prefDelegate(
PreferKey.syncBookProgressPlus,
false
)
var autoCheckNewBackup by prefDelegate(
PreferKey.autoCheckNewBackup,
true
)
var onlyLatestBackup by prefDelegate(
PreferKey.onlyLatestBackup,
true
)
var backupPath by prefDelegate<String?>(
PreferKey.backupPath,
null
)
}
@@ -0,0 +1,615 @@
package io.legado.app.ui.config.backupConfig
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
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.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.help.storage.Restore
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.takePersistablePermissionSafely
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupConfigScreen(
onBackClick: () -> Unit,
viewModel: BackupConfigViewModel = koinViewModel()
) {
val context by rememberUpdatedState(LocalContext.current)
val scope = rememberCoroutineScope()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
var showWebDavAuthDialog by remember { mutableStateOf(false) }
var showBackupIgnoreDialog by remember { mutableStateOf(false) }
var showConfirmDialog by remember { mutableStateOf(false) }
var confirmDialogTitle by remember { mutableStateOf("") }
var confirmDialogText by remember { mutableStateOf("") }
var onConfirmAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var tempAccount by remember { mutableStateOf("") }
var tempPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var showBackupFilePicker by remember { mutableStateOf(false) }
var showRestoreFilePicker by remember { mutableStateOf(false) }
var showRestoreSheet by remember { mutableStateOf(false) }
var backupNames by remember { mutableStateOf<List<String>>(emptyList()) }
var showLoadingDialog by remember { mutableStateOf(false) }
var loadingText by remember { mutableStateOf("") }
val selectBackupPathLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
it.takePersistablePermissionSafely(context)
if (it.isContentScheme()) {
BackupConfig.backupPath = it.toString()
} else {
BackupConfig.backupPath = it.path
}
}
}
val backupAndSelectLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
it.takePersistablePermissionSafely(context)
val path = if (it.isContentScheme()) {
it.toString()
} else {
it.path ?: ""
}
BackupConfig.backupPath = path
if (path.isNotEmpty()) {
startBackup(path, context, viewModel, {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
}
}, { error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_fail,
error
)
)
}
})
}
}
}
val restoreFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
showLoadingDialog = true
loadingText = "恢复中…"
scope.launch {
try {
Restore.restore(context, uri)
showLoadingDialog = false
snackbarHostState.showSnackbar("恢复成功")
} catch (e: Exception) {
showLoadingDialog = false
snackbarHostState.showSnackbar("恢复出错: ${e.localizedMessage}")
}
}
}
}
val importOldLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
io.legado.app.help.storage.ImportOldData.importUri(context, uri)
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState
)
},
topBar = {
GlassMediumFlexibleTopAppBar(
title = {
Text(stringResource(R.string.backup_restore))
},
scrollBehavior = scrollBehavior,
navigationIcon = {
TopbarNavigationButton(onClick = onBackClick)
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
SplicedColumnGroup(title = stringResource(R.string.web_dav_set)) {
InputSettingItem(
title = stringResource(R.string.web_dav_url),
value = BackupConfig.webDavUrl,
defaultValue = "",
onConfirm = { BackupConfig.webDavUrl = it }
)
ClickableSettingItem(
title = stringResource(R.string.web_dav_account),
description = "设置 WebDAV 账号和密码",
onClick = {
tempAccount = viewModel.getWebDavAccount()
tempPassword = viewModel.getWebDavPassword()
showWebDavAuthDialog = true
}
)
InputSettingItem(
title = stringResource(R.string.sub_dir),
value = BackupConfig.webDavDir,
defaultValue = "legado",
onConfirm = { BackupConfig.webDavDir = it }
)
InputSettingItem(
title = stringResource(R.string.webdav_device_name),
value = BackupConfig.webDavDeviceName,
defaultValue = "",
onConfirm = { BackupConfig.webDavDeviceName = it }
)
ClickableSettingItem(
title = "测试 WebDav 配置",
description = "测试 WebDav 服务工作状态",
onClick = {
scope.launch {
showLoadingDialog = true
loadingText = "测试中…"
val success = viewModel.testWebDav()
showLoadingDialog = false
if (success) {
snackbarHostState.showSnackbar("WebDav 配置正确")
} else {
snackbarHostState.showSnackbar("WebDav 配置错误")
}
}
}
)
SwitchSettingItem(
title = stringResource(R.string.sync_book_progress_t),
description = stringResource(R.string.sync_book_progress_s),
checked = BackupConfig.syncBookProgress,
onCheckedChange = {
BackupConfig.syncBookProgress = it
if (!it) {
BackupConfig.syncBookProgressPlus = false
}
}
)
if (BackupConfig.syncBookProgress) {
SwitchSettingItem(
title = stringResource(R.string.sync_book_progress_plus_t),
description = stringResource(R.string.sync_book_progress_plus_s),
checked = BackupConfig.syncBookProgressPlus,
onCheckedChange = { BackupConfig.syncBookProgressPlus = it }
)
}
SwitchSettingItem(
title = stringResource(R.string.auto_check_new_backup_t),
description = stringResource(R.string.auto_check_new_backup_s),
checked = BackupConfig.autoCheckNewBackup,
onCheckedChange = { BackupConfig.autoCheckNewBackup = it }
)
}
SplicedColumnGroup(title = stringResource(R.string.backup_restore)) {
ClickableSettingItem(
title = stringResource(R.string.backup_path),
description = BackupConfig.backupPath
?: stringResource(R.string.select_backup_path),
onClick = { showBackupFilePicker = true }
)
val backupText = stringResource(R.string.backup)
ClickableSettingItem(
title = stringResource(R.string.backup),
description = stringResource(R.string.backup_summary),
onClick = {
val backupPath = BackupConfig.backupPath
if (backupPath.isNullOrEmpty()) {
showRestoreFilePicker = true
} else {
confirmDialogTitle = backupText
confirmDialogText = "确定要备份吗?"
onConfirmAction = {
if (backupPath.isContentScheme()) {
startBackup(backupPath, context, viewModel, {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
}
}, { error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_fail,
error
)
)
}
})
} else {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
startBackup(backupPath, context, viewModel, {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_success
)
)
}
}, { error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_fail,
error
)
)
}
})
}
.request()
}
}
showConfirmDialog = true
}
}
)
ClickableSettingItem(
title = stringResource(R.string.restore),
description = stringResource(R.string.restore_summary),
onClick = {
scope.launch {
showLoadingDialog = true
loadingText = "加载中"
try {
val names = viewModel.getBackupNames()
backupNames = names
showRestoreSheet = true
} catch (e: Exception) {
confirmDialogTitle = "恢复"
confirmDialogText =
"WebDavError\n${e.localizedMessage}\n将从本地备份恢复。"
onConfirmAction = {
restoreFileLauncher.launch(arrayOf("application/zip"))
}
showConfirmDialog = true
} finally {
showLoadingDialog = false
}
}
}
)
ClickableSettingItem(
title = stringResource(R.string.restore_ignore),
description = stringResource(R.string.restore_ignore_summary),
onClick = { showBackupIgnoreDialog = true }
)
ClickableSettingItem(
title = stringResource(R.string.menu_import_old_version),
description = stringResource(R.string.import_old_summary),
onClick = {
importOldLauncher.launch(arrayOf("*/*"))
}
)
SwitchSettingItem(
title = stringResource(R.string.only_latest_backup_t),
description = stringResource(R.string.only_latest_backup_s),
checked = BackupConfig.onlyLatestBackup,
onCheckedChange = { BackupConfig.onlyLatestBackup = it }
)
}
}
}
if (showWebDavAuthDialog) {
AlertDialog(
onDismissRequest = { showWebDavAuthDialog = false },
title = { Text(stringResource(R.string.web_dav_account)) },
text = {
Column {
TextField(
value = tempAccount,
onValueChange = { tempAccount = it },
label = { Text("账号") }
)
Spacer(modifier = Modifier.padding(8.dp))
TextField(
value = tempPassword,
onValueChange = { tempPassword = it },
label = { Text("密码") },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (passwordVisible)
Icons.Filled.Visibility
else Icons.Filled.VisibilityOff
val description = if (passwordVisible) "隐藏密码" else "显示密码"
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
}
)
}
},
confirmButton = {
TextButton(onClick = {
viewModel.setWebDavAccount(tempAccount, tempPassword)
showWebDavAuthDialog = false
scope.launch {
showLoadingDialog = true
loadingText = "测试中…"
val success = viewModel.testWebDav()
showLoadingDialog = false
if (success) {
snackbarHostState.showSnackbar("WebDav 配置正确")
} else {
snackbarHostState.showSnackbar("WebDav 配置错误")
}
}
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showWebDavAuthDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showConfirmDialog) {
ConfirmDialog(
title = confirmDialogTitle,
text = confirmDialogText,
onConfirm = {
onConfirmAction?.invoke()
showConfirmDialog = false
},
onDismiss = { showConfirmDialog = false }
)
}
if (showBackupFilePicker) {
FilePickerSheet(
onDismissRequest = { showBackupFilePicker = false },
onSelectSysDir = {
showBackupFilePicker = false
try {
selectBackupPathLauncher.launch(null)
} catch (e: Exception) {
}
}
)
}
if (showRestoreFilePicker) {
FilePickerSheet(
onDismissRequest = { showRestoreFilePicker = false },
onSelectSysDir = {
showRestoreFilePicker = false
try {
backupAndSelectLauncher.launch(null)
} catch (e: Exception) {
}
}
)
}
if (showRestoreSheet && backupNames.isNotEmpty()) {
AlertDialog(
onDismissRequest = { showRestoreSheet = false },
title = { Text(stringResource(R.string.select_restore_file)) },
text = {
Column {
backupNames.forEach { name ->
TextButton(onClick = {
showRestoreSheet = false
showLoadingDialog = true
loadingText = "恢复中…"
viewModel.restoreWebDav(
name,
{
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar("恢复成功")
}
},
{ error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar("WebDav恢复出错\n$error")
}
}
)
}) {
Text(name)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showRestoreSheet = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showBackupIgnoreDialog) {
val checkedItems = remember {
BooleanArray(io.legado.app.help.storage.BackupConfig.ignoreKeys.size) { index ->
io.legado.app.help.storage.BackupConfig.ignoreConfig[
io.legado.app.help.storage.BackupConfig.ignoreKeys[index]
] ?: false
}
}
AlertDialog(
onDismissRequest = {
io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
showBackupIgnoreDialog = false
},
title = { Text(stringResource(R.string.restore_ignore)) },
text = {
Column {
io.legado.app.help.storage.BackupConfig.ignoreTitle.forEachIndexed { index, title ->
TextButton(onClick = {
checkedItems[index] = !checkedItems[index]
io.legado.app.help.storage.BackupConfig.ignoreConfig[
io.legado.app.help.storage.BackupConfig.ignoreKeys[index]
] = checkedItems[index]
}) {
val isChecked = checkedItems[index]
Text(if (isChecked) "$title" else title)
}
}
}
},
confirmButton = {
TextButton(onClick = {
io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
showBackupIgnoreDialog = false
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
showBackupIgnoreDialog = false
}) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showLoadingDialog) {
AlertDialog(
onDismissRequest = {},
title = { Text(loadingText) },
confirmButton = {},
dismissButton = {}
)
}
}
@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))
}
}
)
}
private fun startBackup(
path: String,
context: android.content.Context,
viewModel: BackupConfigViewModel,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
viewModel.backup(path, onSuccess, onError)
}
@@ -0,0 +1,74 @@
package io.legado.app.ui.config.backupConfig
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.help.AppWebDav
import io.legado.app.help.storage.Backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
class BackupConfigViewModel : ViewModel() {
fun setWebDavAccount(account: String, password: String) {
BackupConfig.webDavAccount = account
BackupConfig.webDavPassword = password
}
fun getWebDavAccount(): String {
return BackupConfig.webDavAccount
}
fun getWebDavPassword(): String {
return BackupConfig.webDavPassword
}
suspend fun testWebDav(): Boolean {
return withContext(Dispatchers.IO) {
try {
AppWebDav.testWebDav()
true
} catch (e: Exception) {
false
}
}
}
fun backup(backupPath: String, onSuccess: () -> Unit, onError: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
try {
Backup.backupLocked(appCtx, backupPath)
withContext(Dispatchers.Main) {
onSuccess()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
onError(e.localizedMessage ?: "备份出错")
}
}
}
}
suspend fun getBackupNames(): List<String> {
return withContext(Dispatchers.IO) {
AppWebDav.getBackupNames()
}
}
fun restoreWebDav(name: String, onSuccess: () -> Unit, onError: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
try {
AppWebDav.restoreWebDav(name)
withContext(Dispatchers.Main) {
onSuccess()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
onError(e.localizedMessage ?: "恢复出错")
}
}
}
}
}
@@ -39,7 +39,6 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackgroundImageManageSheet(
preferenceKey: String,
isDarkTheme: Boolean,
onDismissRequest: () -> Unit,
viewModel: ThemeConfigViewModel = koinViewModel()
@@ -53,7 +52,7 @@ fun BackgroundImageManageSheet(
scope.launch {
viewModel.setBackgroundFromUri(
uri = it,
preferenceKey = preferenceKey
isDarkTheme = isDarkTheme
)
}
}
@@ -118,7 +117,7 @@ fun BackgroundImageManageSheet(
)
}
IconButton(
onClick = { viewModel.removeBackground(preferenceKey) },
onClick = { viewModel.removeBackground(isDarkTheme) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
@@ -0,0 +1,200 @@
package io.legado.app.ui.config.themeConfig
import android.content.ComponentName
import android.widget.ImageView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.legado.app.R
import io.legado.app.ui.main.Launcher0
import io.legado.app.ui.main.Launcher1
import io.legado.app.ui.main.Launcher2
import io.legado.app.ui.main.Launcher3
import io.legado.app.ui.main.Launcher4
import io.legado.app.ui.main.Launcher5
import io.legado.app.ui.main.Launcher6
import io.legado.app.ui.main.LauncherW
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.utils.getCompatDrawable
import splitties.init.appCtx
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LauncherIconPickerSheet(
selectedValue: String,
onDismissRequest: () -> Unit,
onValueChange: (String) -> Unit
) {
val context = LocalContext.current
val icons = LauncherIcons.list
GlassModalBottomSheet(
onDismissRequest = onDismissRequest
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.change_icon),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(icons, key = { it.value }) { item ->
val isSelected = item.value == selectedValue
val drawable = remember(item.resId) {
context.getCompatDrawable(item.resId)
}
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(MaterialTheme.shapes.large)
.background(
if (isSelected)
MaterialTheme.colorScheme.secondaryContainer
else
MaterialTheme.colorScheme.surfaceContainer
)
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.large
)
} else {
Modifier
}
)
.clickable {
onValueChange(item.value)
onDismissRequest()
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
ImageView(ctx).apply {
scaleType = ImageView.ScaleType.FIT_CENTER
}
},
update = { imageView ->
imageView.setImageDrawable(drawable)
},
modifier = Modifier.size(48.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
data class LauncherIconItem(
val value: String,
val label: String,
val resId: Int,
val component: ComponentName
)
object LauncherIcons {
val list = listOf(
LauncherIconItem(
value = "ic_launcher",
label = "iconMain",
resId = R.mipmap.ic_launcher,
component = ComponentName(appCtx, LauncherW::class.java)
),
LauncherIconItem(
value = "launcherw",
label = "iconWhite",
resId = R.mipmap.launcherw,
component = ComponentName(appCtx, LauncherW::class.java)
),
LauncherIconItem(
value = "launcher0",
label = "icon0",
resId = R.mipmap.launcher0,
component = ComponentName(appCtx, Launcher0::class.java)
),
LauncherIconItem(
value = "launcher1",
label = "icon1",
resId = R.mipmap.launcher1,
component = ComponentName(appCtx, Launcher1::class.java)
),
LauncherIconItem(
value = "launcher2",
label = "icon2",
resId = R.mipmap.launcher2,
component = ComponentName(appCtx, Launcher2::class.java)
),
LauncherIconItem(
value = "launcher3",
label = "icon3",
resId = R.mipmap.launcher3,
component = ComponentName(appCtx, Launcher3::class.java)
),
LauncherIconItem(
value = "launcher4",
label = "icon4",
resId = R.mipmap.launcher4,
component = ComponentName(appCtx, Launcher4::class.java)
),
LauncherIconItem(
value = "launcher5",
label = "icon5",
resId = R.mipmap.launcher5,
component = ComponentName(appCtx, Launcher5::class.java)
),
LauncherIconItem(
value = "launcher6",
label = "icon6",
resId = R.mipmap.launcher6,
component = ComponentName(appCtx, Launcher6::class.java)
),
)
fun find(value: String?): LauncherIconItem? {
return list.find { it.value == value }
}
}
@@ -1,6 +1,5 @@
package io.legado.app.ui.config.themeConfig
import android.util.Log
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
@@ -20,6 +19,8 @@ object ThemeConfig {
var appTheme by prefDelegate(PreferKey.appTheme, "0")
var themeMode by prefDelegate(PreferKey.themeMode, "0")
var isPureBlack by prefDelegate(PreferKey.pureBlack, false)
var bgImageLight by prefDelegate<String?>(PreferKey.bgImage, null) {
@@ -34,28 +35,37 @@ object ThemeConfig {
var bgImageNBlurring by prefDelegate(PreferKey.bgImageNBlurring, 0)
fun hasImageBg(isDark: Boolean): Boolean {
val result = if (isDark) {
!bgImageDark.isNullOrBlank()
} else {
!bgImageLight.isNullOrBlank()
}
var isPredictiveBackEnabled by prefDelegate(PreferKey.isPredictiveBackEnabled, true)
Log.d(
"MainConfig",
"hasImageBg -> isDark=$isDark, " +
"bgImageDark=$bgImageDark, " +
"bgImageLight=$bgImageLight, " +
"result=$result"
)
var customMode by prefDelegate<String?>(PreferKey.customMode, "tonalSpot")
return result
var fontScale by prefDelegate(PreferKey.fontScale, 10) {
postEvent(EventBus.RECREATE, "")
}
/*
fun hasImageBg(isDark: Boolean): Boolean {
return if (isDark) bgImageDark.isNullOrBlank() else bgImageLight.isNullOrBlank()
var cPrimary by prefDelegate(PreferKey.cPrimary, 0) {
postEvent(EventBus.RECREATE, "")
}
*/
var launcherIcon by prefDelegate(PreferKey.launcherIcon, "ic_launcher")
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRss by prefDelegate(PreferKey.showRss, true)
var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)
var showBottomView by prefDelegate(PreferKey.showBottomView, true)
var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
fun hasImageBg(isDark: Boolean): Boolean =
!(if (isDark) bgImageDark else bgImageLight).isNullOrBlank()
}
@@ -1,42 +1,116 @@
package io.legado.app.ui.config.themeConfig
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessMedium
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.clip
import androidx.compose.ui.graphics.Color
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.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.base.AppContextWrapper
import io.legado.app.constant.EventBus
import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.lib.theme.ThemeStore
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.theme.ThemeManager
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
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.SliderSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.utils.postEvent
import io.legado.app.utils.restart
import io.legado.app.utils.toastOnUi
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ThemeConfigScreen(
onBackClick: () -> Unit,
viewModel: ThemeConfigViewModel = koinViewModel()
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
var manageKey by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
var manageKey by remember { mutableStateOf<Boolean?>(null) }
val context = LocalContext.current
var selectedThemeMode by remember { mutableStateOf(ThemeConfig.themeMode) }
var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) }
var showRestartDialog by remember { mutableStateOf(false) }
var showColorPicker by remember { mutableStateOf(false) }
var showLauncherIconPicker by remember { mutableStateOf(false) }
val fontScaleValue = remember { mutableFloatStateOf(ThemeConfig.fontScale.toFloat()) }
val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) }
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -57,24 +131,197 @@ fun ThemeConfigScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
val isDarkTheme = when (selectedThemeMode) {
"1" -> false
"2" -> true
else -> isSystemInDarkTheme()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
ThemeCard(
context = context,
value = selectedTheme,
isDark = isDarkTheme,
isAmoled = ThemeConfig.isPureBlack,
paletteStyle = ThemeConfig.paletteStyle
)
}
Spacer(modifier = Modifier.height(16.dp))
val themeItems = stringArrayResource(R.array.themes_item)
val themeValues = stringArrayResource(R.array.themes_value)
val themes = remember(themeItems, themeValues) {
themeItems.zip(themeValues).toList()
}
SplicedColumnGroup(title = stringResource(R.string.theme)) {
ThemeModeSelector(
selectedMode = selectedThemeMode,
onModeSelected = { mode ->
selectedThemeMode = mode
ThemeConfig.themeMode = mode
OldThemeConfig.applyDayNight(context)
}
)
Spacer(modifier = Modifier.height(16.dp))
ThemeColorSelector(
context = context,
themes = themes,
selectedTheme = selectedTheme,
isDark = isDarkTheme,
isAmoled = ThemeConfig.isPureBlack,
paletteStyle = ThemeConfig.paletteStyle,
onThemeSelected = { theme ->
if (theme == "13") {
val hasLightBg = !ThemeConfig.bgImageLight.isNullOrEmpty()
val hasDarkBg = !ThemeConfig.bgImageDark.isNullOrEmpty()
if (!hasLightBg || !hasDarkBg) {
context.toastOnUi(R.string.transparent_theme_alarm)
return@ThemeColorSelector
} else {
AppConfig.containerOpacity = 0
}
}
val oldTheme = selectedTheme
selectedTheme = theme
ThemeConfig.appTheme = theme
val isDynamicSwitch = (oldTheme == "12" || theme == "12")
if (isDynamicSwitch) {
showRestartDialog = true
} else {
postEvent(EventBus.RECREATE, "")
}
}
)
}
SplicedColumnGroup {
SwitchSettingItem(
title = stringResource(R.string.pure_black),
checked = ThemeConfig.isPureBlack,
onCheckedChange = { ThemeConfig.isPureBlack = it }
)
ClickableSettingItem(
title = stringResource(R.string.change_icon),
description = stringResource(R.string.change_icon_summary),
onClick = { showLauncherIconPicker = true }
)
SwitchSettingItem(
title = "预见性返回手势",
description = "启用系统预见性返回手势",
checked = ThemeConfig.isPredictiveBackEnabled,
onCheckedChange = {
ThemeConfig.isPredictiveBackEnabled = it
context.toastOnUi("重启以应用")
}
)
SliderSettingItem(
title = stringResource(R.string.font_scale),
description = stringResource(
R.string.font_scale_summary,
AppContextWrapper.getFontScale(context)
),
value = fontScaleValue.value,
defaultValue = 10f,
valueRange = 8f..16f,
steps = 7,
onValueChange = { value ->
fontScaleValue.floatValue = value
ThemeConfig.fontScale = value.toInt()
}
)
}
if (selectedTheme == "12") {
SplicedColumnGroup(title = "自定义主题") {
ClickableSettingItem(
title = stringResource(R.string.seed_color),
option = if (primaryColorValue.intValue != 0) "#${
Integer.toHexString(
primaryColorValue.intValue
).uppercase()
}" else "点击选择",
onClick = { showColorPicker = true },
trailingContent = {
if (primaryColorValue.intValue != 0) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(primaryColorValue.intValue))
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape
)
)
}
}
)
DropdownListSettingItem(
title = stringResource(R.string.palette_style),
selectedValue = ThemeConfig.paletteStyle,
displayEntries = stringArrayResource(R.array.paletteStyle),
entryValues = stringArrayResource(R.array.paletteStyle_value),
onValueChange = { ThemeConfig.paletteStyle = it }
)
}
}
SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
SwitchSettingItem(
title = stringResource(R.string.show_discovery),
checked = ThemeConfig.showDiscovery,
onCheckedChange = { ThemeConfig.showDiscovery = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_rss),
checked = ThemeConfig.showRss,
onCheckedChange = { ThemeConfig.showRss = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_status),
checked = ThemeConfig.showStatusBar,
onCheckedChange = { ThemeConfig.showStatusBar = it }
)
//TODO:这个可以不要了,在删掉原来的设置页以后删
SwitchSettingItem(
title = stringResource(R.string.show_swipe_animation),
checked = ThemeConfig.swipeAnimation,
onCheckedChange = { ThemeConfig.swipeAnimation = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_bottom_nav),
description = stringResource(R.string.be_swiped),
checked = ThemeConfig.showBottomView,
onCheckedChange = { ThemeConfig.showBottomView = it }
)
DropdownListSettingItem(
title = stringResource(R.string.palette_style),
selectedValue = ThemeConfig.paletteStyle,
displayEntries = arrayOf(
"柔和", "中性", "鲜艳", "表现力", "彩虹",
"水果拼盘", "单色", "仿真", "内容取色"
),
entryValues = arrayOf(
"tonalSpot", "neutral", "vibrant", "expressive", "rainbow",
"fruitSalad", "monochrome", "fidelity", "content"
),
onValueChange = { ThemeConfig.paletteStyle = it }
title = stringResource(R.string.tabletInterface),
selectedValue = ThemeConfig.tabletInterface,
displayEntries = stringArrayResource(R.array.tabletInterface),
entryValues = stringArrayResource(R.array.tabletInterface_value),
onValueChange = { ThemeConfig.tabletInterface = it }
)
DropdownListSettingItem(
title = stringResource(R.string.nav_label_mode),
selectedValue = ThemeConfig.labelVisibilityMode,
displayEntries = stringArrayResource(R.array.label_vis_mode),
entryValues = stringArrayResource(R.array.label_vis_mode_value),
onValueChange = { ThemeConfig.labelVisibilityMode = it }
)
DropdownListSettingItem(
title = stringResource(R.string.default_home_page),
selectedValue = ThemeConfig.defaultHomePage,
displayEntries = stringArrayResource(R.array.default_home_page),
entryValues = stringArrayResource(R.array.default_home_page_value),
onValueChange = { ThemeConfig.defaultHomePage = it }
)
}
@@ -117,7 +364,7 @@ fun ThemeConfigScreen(
description = if (hasLightBg) stringResource(R.string.click_to_delete) else stringResource(
R.string.select_image
),
onClick = { manageKey = Pair(PreferKey.bgImage, false) }
onClick = { manageKey = false }
)
if (hasLightBg) {
@@ -141,7 +388,7 @@ fun ThemeConfigScreen(
description = if (hasDarkBg) stringResource(R.string.click_to_delete) else stringResource(
R.string.select_image
),
onClick = { manageKey = Pair(PreferKey.bgImageN, true) }
onClick = { manageKey = true }
)
if (hasDarkBg) {
@@ -160,11 +407,394 @@ fun ThemeConfigScreen(
}
}
manageKey?.let { (key, isDark) ->
if (showRestartDialog) {
AlertDialog(
onDismissRequest = { showRestartDialog = false },
title = { Text(stringResource(R.string.restart_required_message)) },
confirmButton = {
OutlinedButton(onClick = {
showRestartDialog = false
Handler(Looper.getMainLooper()).postDelayed({
context.restart()
}, 100)
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
showRestartDialog = false
context.toastOnUi(R.string.restart_later_message)
}) {
Text(stringResource(R.string.cancel))
}
}
)
}
manageKey?.let { isDark ->
BackgroundImageManageSheet(
preferenceKey = key,
isDarkTheme = isDark,
onDismissRequest = { manageKey = null }
)
}
if (showColorPicker) {
ColorPickerSheet(
initialColor = primaryColorValue.value,
onDismissRequest = { showColorPicker = false },
onColorSelected = { color ->
primaryColorValue.value = color
ThemeConfig.cPrimary = color
ThemeStore.editTheme(context)
.primaryColor(color)
.apply()
DynamicColors.applyToActivitiesIfAvailable(
context.applicationContext as android.app.Application,
DynamicColorsOptions.Builder()
.setContentBasedSource(context.primaryColor)
.build()
)
}
)
}
if (showLauncherIconPicker) {
LauncherIconPickerSheet(
selectedValue = ThemeConfig.launcherIcon,
onDismissRequest = { showLauncherIconPicker = false },
onValueChange = {
ThemeConfig.launcherIcon = it
LauncherIconHelp.changeIcon(it)
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ThemeModeSelector(
selectedMode: String,
onModeSelected: (String) -> Unit
) {
val modes = listOf(
Triple("0", stringResource(R.string.flow_sys), Icons.Filled.BrightnessMedium),
Triple("1", stringResource(R.string.light_mode), Icons.Filled.LightMode),
Triple("2", stringResource(R.string.dark_mode), Icons.Filled.DarkMode)
)
val selectedIndex = modes.indexOfFirst { it.first == selectedMode }
.coerceAtLeast(0)
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
ButtonGroupDefaults.ConnectedSpaceBetween
)
) {
val modifiers = listOf(Modifier.weight(1.2f), Modifier.weight(1f), Modifier.weight(1f))
modes.forEachIndexed { index, (value, label, icon) ->
ToggleButton(
checked = selectedIndex == index,
onCheckedChange = { onModeSelected(value) },
modifier = modifiers[index]
.semantics { role = Role.RadioButton },
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
modes.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
}
) {
Icon(
imageVector = icon,
contentDescription = null
)
Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing))
Text(text = label)
}
}
}
}
@Composable
fun ThemeColorSelector(
context: Context,
themes: List<Pair<String, String>>,
selectedTheme: String,
isDark: Boolean,
isAmoled: Boolean,
paletteStyle: String?,
onThemeSelected: (String) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(themes) { (label, value) ->
ThemeColorButton(
context = context,
label = label,
value = value,
isSelected = selectedTheme == value,
isDark = isDark,
isAmoled = isAmoled,
paletteStyle = paletteStyle,
onClick = { onThemeSelected(value) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThemeColorButton(
context: Context,
label: String,
value: String,
isSelected: Boolean,
isDark: Boolean,
isAmoled: Boolean,
paletteStyle: String?,
onClick: () -> Unit
) {
val colors = getThemeColorPalette(context, value, isDark, isAmoled, paletteStyle)
val borderWidth by animateDpAsState(
targetValue = if (isSelected) 2.dp else 0.dp,
label = "borderWidth"
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Card(
onClick = onClick,
modifier = Modifier.size(64.dp),
shape = RoundedCornerShape(16.dp),
border = if (isSelected) BorderStroke(
borderWidth,
MaterialTheme.colorScheme.primary
) else null,
colors = CardDefaults.cardColors(containerColor = colors.surfaceContainer)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.size(48.dp)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
drawArc(
color = colors.secondary,
startAngle = -90f,
sweepAngle = 180f,
useCenter = true,
size = size
)
drawArc(
color = colors.tertiary,
startAngle = 90f,
sweepAngle = 180f,
useCenter = true,
size = size
)
}
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(colors.primary)
.align(Alignment.Center)
)
}
if (isSelected) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
fun ThemeCard(
context: Context,
value: String,
isDark: Boolean,
isAmoled: Boolean,
paletteStyle: String?
) {
val colors = getThemeColors(context, value, isDark, isAmoled, paletteStyle)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier
.width(128.dp)
.height(256.dp),
shape = MaterialTheme.shapes.large,
border = BorderStroke(1.dp, colors.primary),
colors = CardDefaults.cardColors(containerColor = colors.surface)
) {
ConstraintLayout(
modifier = Modifier.fillMaxSize()
) {
val (colorTop, colorBook, colorBottom) = createRefs()
Box(
modifier = Modifier
.size(width = 48.dp, height = 16.dp)
.constrainAs(colorTop) {
top.linkTo(parent.top, margin = 12.dp)
start.linkTo(parent.start, margin = 12.dp)
}
.clip(RoundedCornerShape(8.dp))
.background(colors.onSurfaceVariant)
)
Box(
modifier = Modifier
.size(width = 56.dp, height = 80.dp)
.constrainAs(colorBook) {
top.linkTo(colorTop.bottom, margin = 8.dp)
start.linkTo(colorTop.start)
}
.clip(RoundedCornerShape(8.dp))
.background(colors.secondaryContainer)
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(width = 16.dp, height = 12.dp)
.clip(RoundedCornerShape(4.dp))
.background(colors.secondary)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.constrainAs(colorBottom) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start, margin = 4.dp)
end.linkTo(parent.end, margin = 4.dp)
}
.background(colors.surfaceContainer)
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(20.dp)
.clip(CircleShape)
.background(colors.primary)
)
}
}
}
}
}
data class ThemeColorPalette(
val primary: Color,
val secondary: Color,
val tertiary: Color,
val surfaceContainer: Color
)
data class ThemeColors(
val primary: Color,
val secondary: Color,
val tertiary: Color,
val surface: Color,
val surfaceContainer: Color,
val secondaryContainer: Color,
val onSurface: Color,
val onSurfaceVariant: Color
)
@SuppressLint("ResourceType")
private fun getThemeColorPalette(
context: Context,
value: String,
isDark: Boolean,
isAmoled: Boolean,
paletteStyle: String?
): ThemeColorPalette {
val appThemeMode = ThemeResolver.resolveThemeMode(value)
val colorScheme = ThemeManager.getColorScheme(
context = context,
mode = appThemeMode,
darkTheme = isDark,
isAmoled = isAmoled,
paletteStyle = paletteStyle
)
return ThemeColorPalette(
primary = colorScheme.primary,
secondary = colorScheme.secondaryContainer,
tertiary = colorScheme.tertiaryContainer,
surfaceContainer = colorScheme.surfaceContainer
)
}
@SuppressLint("ResourceType")
private fun getThemeColors(
context: Context,
value: String,
isDark: Boolean,
isAmoled: Boolean,
paletteStyle: String?
): ThemeColors {
val appThemeMode = ThemeResolver.resolveThemeMode(value)
val colorScheme = ThemeManager.getColorScheme(
context = context,
mode = appThemeMode,
darkTheme = isDark,
isAmoled = isAmoled,
paletteStyle = paletteStyle
)
return ThemeColors(
primary = colorScheme.primary,
secondary = colorScheme.secondary,
tertiary = colorScheme.tertiary,
surface = colorScheme.surface,
surfaceContainer = colorScheme.surfaceContainer,
secondaryContainer = colorScheme.secondaryContainer,
onSurface = colorScheme.onSurface,
onSurfaceVariant = colorScheme.onSurfaceVariant
)
}
@@ -21,7 +21,7 @@ class ThemeConfigViewModel : ViewModel() {
*/
suspend fun setBackgroundFromUri(
uri: Uri,
preferenceKey: String
isDarkTheme: Boolean
) = withContext(Dispatchers.IO) {
kotlin.runCatching {
val fileDoc = FileDoc.fromUri(uri, false)
@@ -33,6 +33,7 @@ class ThemeConfigViewModel : ViewModel() {
}
val fileName = "$md5.$suffix"
val preferenceKey = if (isDarkTheme) PreferKey.bgImageN else PreferKey.bgImage
val folder = File(appCtx.externalFiles, preferenceKey)
val file = File(folder, fileName)
@@ -45,24 +46,24 @@ class ThemeConfigViewModel : ViewModel() {
}
}
updateBackgroundPath(preferenceKey, file.absolutePath)
updateBackgroundPath(isDarkTheme, file.absolutePath)
}.onFailure {
it.printStackTrace()
}
}
fun removeBackground(preferenceKey: String) {
updateBackgroundPath(preferenceKey, null)
fun removeBackground(isDarkTheme: Boolean) {
updateBackgroundPath(isDarkTheme, null)
}
/**
* 更新背景路径并清理旧文件
*/
private fun updateBackgroundPath(preferenceKey: String, newPath: String?) {
val oldPath = when (preferenceKey) {
PreferKey.bgImage -> ThemeConfig.bgImageLight
PreferKey.bgImageN -> ThemeConfig.bgImageDark
else -> null
private fun updateBackgroundPath(isDarkTheme: Boolean, newPath: String?) {
val oldPath = if (isDarkTheme) {
ThemeConfig.bgImageDark
} else {
ThemeConfig.bgImageLight
}
if (oldPath != newPath && oldPath != null) {
@@ -72,9 +73,10 @@ class ThemeConfigViewModel : ViewModel() {
}
}
when (preferenceKey) {
PreferKey.bgImage -> ThemeConfig.bgImageLight = newPath
PreferKey.bgImageN -> ThemeConfig.bgImageDark = newPath
if (isDarkTheme) {
ThemeConfig.bgImageDark = newPath
} else {
ThemeConfig.bgImageLight = newPath
}
}
}
@@ -198,76 +198,87 @@ fun BookshelfItem(
@Composable
fun BookGroupCover(
books: List<Book>,
coverPath: String? = null,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.aspectRatio(5f / 7f)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(0)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
if (coverPath != null) {
BookCover(
name = "",
author = "",
path = coverPath,
modifier = Modifier.fillMaxSize(),
ignoreUseDefaultCover = true
)
} else {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(0)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(1)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(1)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(2)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
}
}
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(2)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(3)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(3)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@@ -292,7 +303,7 @@ fun BookGroupItemGrid(
isGrid = true,
gridStyle = gridStyle,
isCompact = false,
cover = { BookGroupCover(books = previewBooks, modifier = it) },
cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
title = group.groupName,
modifier = modifier,
titleSmallFont = titleSmallFont,
@@ -321,7 +332,7 @@ fun BookGroupItemList(
isGrid = false,
gridStyle = 0,
isCompact = isCompact,
cover = { BookGroupCover(books = previewBooks, modifier = it) },
cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
title = group.groupName,
titleSmallFont = titleSmallFont,
titleCenter = titleCenter,
@@ -5,6 +5,7 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -162,6 +163,17 @@ fun BookshelfScreen(
pageCount = { uiState.groups.size }
)
LaunchedEffect(uiState.groups) {
if (uiState.groups.isNotEmpty()) {
val savedGroupId = BookshelfConfig.saveTabPosition
val savedGroupIndex = uiState.groups.indexOfFirst { it.groupId == savedGroupId }
if (savedGroupIndex >= 0 && savedGroupIndex != pagerState.currentPage) {
viewModel.changeGroup(savedGroupId)
pagerState.scrollToPage(savedGroupIndex)
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
@@ -348,75 +360,84 @@ fun BookshelfScreen(
enabled = pullToRefreshEnabled
)
) {
if (bookGroupStyle == 2 && isInFolderRoot) {
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(bookshelfLayoutGrid.coerceAtLeast(1)),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp,
start = if (bookshelfLayoutMode != 0) 12.dp else 0.dp,
end = if (bookshelfLayoutMode != 0) 12.dp else 0.dp
),
verticalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
showFastScroll = BookshelfConfig.showBookshelfFastScroller
) {
itemsIndexed(uiState.groups, key = { _, it -> it.groupId }) { index, group ->
if (bookshelfLayoutMode == 0) {
BookGroupItemList(
group = group,
previewBooks = uiState.groupPreviews[group.groupId] ?: emptyList(),
isCompact = BookshelfConfig.bookshelfLayoutCompact,
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
} else {
BookGroupItemGrid(
group = group,
previewBooks = uiState.groupPreviews[group.groupId] ?: emptyList(),
gridStyle = BookshelfConfig.bookshelfGridLayout,
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
AnimatedContent(
targetState = isInFolderRoot,
label = "FolderTransition"
) { isRoot ->
if (bookGroupStyle == 2 && isRoot) {
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(bookshelfLayoutGrid.coerceAtLeast(1)),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp,
start = if (bookshelfLayoutMode != 0) 12.dp else 0.dp,
end = if (bookshelfLayoutMode != 0) 12.dp else 0.dp
),
verticalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
showFastScroll = BookshelfConfig.showBookshelfFastScroller
) {
itemsIndexed(
uiState.groups,
key = { _, it -> it.groupId }) { index, group ->
if (bookshelfLayoutMode == 0) {
BookGroupItemList(
group = group,
previewBooks = uiState.groupPreviews[group.groupId]
?: emptyList(),
isCompact = BookshelfConfig.bookshelfLayoutCompact,
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
} else {
BookGroupItemGrid(
group = group,
previewBooks = uiState.groupPreviews[group.groupId]
?: emptyList(),
gridStyle = BookshelfConfig.bookshelfGridLayout,
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
}
}
}
}
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 3,
key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex)
if (group != null) {
val booksFlow = remember(group.groupId) {
viewModel.getBooksFlow(group.groupId)
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 3,
key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex)
if (group != null) {
val booksFlow = remember(group.groupId) {
viewModel.getBooksFlow(group.groupId)
}
val books by booksFlow.collectAsState(emptyList())
BookshelfPage(
paddingValues = paddingValues,
books = books,
uiState = uiState,
bookshelfLayoutMode = bookshelfLayoutMode,
bookshelfLayoutGrid = bookshelfLayoutGrid,
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
)
}
val books by booksFlow.collectAsState(emptyList())
BookshelfPage(
paddingValues = paddingValues,
books = books,
uiState = uiState,
bookshelfLayoutMode = bookshelfLayoutMode,
bookshelfLayoutGrid = bookshelfLayoutGrid,
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
)
}
}
}
@@ -138,11 +138,18 @@ class BookshelfViewModel(
if (BookshelfConfig.bookGroupStyle in 2..3) {
groups.associate { group ->
val groupBooks = if (group.groupId == BookGroup.IdAll) {
allBooks.take(4)
allBooks
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
allBooks.filter { (it.group and group.groupId) != 0L }
}
group.groupId to groupBooks
val sortedBooks = sortBooks(groupBooks, group)
val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
val result = if (booksWithCover.size >= 4) {
booksWithCover.take(4)
} else {
(booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4)
}
group.groupId to result
}
} else {
emptyMap()
@@ -1,39 +1,91 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.chrisbanes.haze.HazeState
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppScaffold(
modifier: Modifier = Modifier,
title: (@Composable () -> Unit)? = null,
subtitle: (@Composable () -> Unit)? = null,
scrollBehavior: TopAppBarScrollBehavior? = null,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
colors: TopAppBarColors = GlassTopAppBarDefaults.glassColors(),
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
containerColor: Color = Color.Transparent,
contentColor: Color = contentColorFor(MaterialTheme.colorScheme.background),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit
) {
val isDark = isSystemInDarkTheme()
val hasImageBg = ThemeConfig.hasImageBg(isDark)
val hazeState = remember { HazeState() }
val containerColor = if (hasImageBg) {
Color.Transparent
} else {
MaterialTheme.colorScheme.background
}
val finalTopBar = @Composable {
Box(modifier = Modifier.responsiveHazeEffect(state = hazeState)) {
if (title != null) {
GlassMediumFlexibleTopAppBar(
title = title,
subtitle = subtitle,
scrollBehavior = scrollBehavior,
navigationIcon = navigationIcon,
actions = actions,
colors = colors
)
} else {
topBar()
}
}
}
Scaffold(
modifier = modifier,
topBar = topBar,
topBar = finalTopBar,
bottomBar = bottomBar,
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
containerColor = containerColor,
contentColor = contentColor,
contentWindowInsets = contentWindowInsets,
content = content
)
}
contentWindowInsets = contentWindowInsets
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.responsiveHazeSource(hazeState)
) {
content(paddingValues)
}
}
}
@@ -75,10 +75,7 @@ object GlassTopAppBarDefaults {
return TopAppBarDefaults.topAppBarColors(
containerColor = containerColor,
scrolledContainerColor = scrolledContainerColor,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant
scrolledContainerColor = scrolledContainerColor
)
}
@@ -7,6 +7,7 @@ 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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -14,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.card.GlassCard
/**
* Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived
@@ -29,17 +29,17 @@ fun SplicedColumnGroup(
title: String = "",
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.padding(top = 4.dp, bottom = 12.dp)) {
Column(modifier = modifier.padding(top = 8.dp, bottom = 16.dp)) {
if (title.isNotEmpty()) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
}
GlassCard(
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth()
@@ -26,11 +26,18 @@ fun GlassCard(
) {
val containerAlpha = ThemeConfig.containerOpacity / 100f
val finalColors = colors ?: CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = containerAlpha),
val baseColors = colors ?: CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
val finalColors = CardDefaults.cardColors(
containerColor = baseColors.containerColor.copy(alpha = containerAlpha),
contentColor = baseColors.contentColor,
disabledContainerColor = baseColors.disabledContainerColor.copy(alpha = containerAlpha),
disabledContentColor = baseColors.disabledContentColor
)
if (onClick != null) {
Card(
onClick = onClick,
@@ -54,17 +54,25 @@ fun BookCover(
badgeText: String? = null,
showBadgeDot: Boolean = false,
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null
onLoadFinish: (() -> Unit)? = null,
ignoreUseDefaultCover: Boolean = false
) {
val context = LocalContext.current
val isNight = isSystemInDarkTheme()
val useDefault = CoverConfig.useDefaultCover
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
val finalPath = if (useDefault) null else path
// 获取随机封面路径(字符串),而非直接获取 Drawable,避免主线程解码大图
val randomPath = remember(name, author, path, isNight) {
BookCover.getRandomDefaultPath(seed = name ?: author ?: path ?: "")
// 获取随机封面路径(字符串),增加配置项作为 Key,确保设置更改后立即刷新
val randomPath = remember(
name, author, path, isNight,
CoverConfig.defaultCover,
CoverConfig.defaultCoverDark
) {
BookCover.getRandomDefaultPath(
seed = name ?: author ?: path ?: "",
isNight = isNight
)
}
// 检查是否有自定义随机封面设置
@@ -95,7 +95,7 @@ fun ColorPickerSheet(
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.height(240.dp)
.padding(10.dp),
controller = controller,
initialColor = Color(initialColor),
@@ -122,34 +122,6 @@ fun ColorPickerSheet(
controller = controller,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(currentColor)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(8.dp)
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "#${Integer.toHexString(currentColor.toArgb()).uppercase()}",
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.height(16.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(36.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -176,21 +148,45 @@ fun ColorPickerSheet(
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onColorSelected(currentColor.toArgb())
onDismissRequest()
}) {
Text(stringResource(R.string.ok))
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(currentColor)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(8.dp)
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "#${Integer.toHexString(currentColor.toArgb()).uppercase()}",
style = MaterialTheme.typography.bodyLarge
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onColorSelected(currentColor.toArgb())
onDismissRequest()
}) {
Text(stringResource(R.string.ok))
}
}
}
}
@@ -145,7 +145,6 @@ fun <T> BatchImportDialog(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
containerColor = Color.Transparent,
topBar = {
CenterAlignedTopAppBar(
title = {
@@ -35,9 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.SelectionActions
@@ -84,7 +81,6 @@ fun <T> ListScaffold(
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val hazeState = remember { HazeState() }
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -96,29 +92,25 @@ fun <T> ListScaffold(
)
},
topBar = {
Box(modifier = Modifier.responsiveHazeEffect(state = hazeState)) {
DynamicTopAppBar(
title = title,
subtitle = subtitle,
state = state,
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
onSearchToggle = onSearchToggle,
onSearchQueryChange = onSearchQueryChange,
searchPlaceholder = searchPlaceholder,
onClearSelection = { selectionActions?.onSelectInvert?.invoke() },
topBarActions = topBarActions,
dropDownMenuContent = dropDownMenuContent,
bottomContent = bottomContent
)
}
DynamicTopAppBar(
title = title,
subtitle = subtitle,
state = state,
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
onSearchToggle = onSearchToggle,
onSearchQueryChange = onSearchQueryChange,
searchPlaceholder = searchPlaceholder,
onClearSelection = { selectionActions?.onSelectInvert?.invoke() },
topBarActions = topBarActions,
dropDownMenuContent = dropDownMenuContent,
bottomContent = bottomContent
)
},
floatingActionButton = floatingActionButton
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.responsiveHazeSource(hazeState)
modifier = Modifier.fillMaxSize()
) {
content(paddingValues)
@@ -50,9 +50,8 @@ fun SliderSettingItem(
SettingItem(
title = title,
description = description,
color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
option = value.toInt().toString(),
option = description,
expanded = expanded,
onExpandChange = { expanded = it },
expandContent = {
@@ -23,6 +23,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.model.BookCover
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.utils.spToPx
import io.legado.app.utils.textHeight
import io.legado.app.utils.themeColor
@@ -46,9 +47,9 @@ class CoverImageView @JvmOverloads constructor(
private var author: String? = null
private var nameHeight = 0f
private var authorHeight = 0f
private val isNightTheme = AppConfig.isNightTheme
val colorKey = if (isNightTheme) BookCover.coverTextColorN else BookCover.coverTextColor
val shadowKey = if (isNightTheme) BookCover.coverShadowColorN else BookCover.coverShadowColor
private val isNightTheme get() = AppConfig.isNightTheme
private val colorKey get() = if (isNightTheme) CoverConfig.coverTextColorN else CoverConfig.coverTextColor
private val shadowKey get() = if (isNightTheme) CoverConfig.coverShadowColorN else CoverConfig.coverShadowColor
private val namePaint by lazy {
val textPaint = TextPaint()
textPaint.typeface = Typeface.DEFAULT_BOLD
@@ -123,13 +124,14 @@ class CoverImageView @JvmOverloads constructor(
private fun drawNameAuthor(canvas: Canvas) {
var startX = width * 0.2f
var startY = viewHeight * 0.2f
if (BookCover.drawBookName) {
val showName = if (isNightTheme) CoverConfig.coverShowNameN else CoverConfig.coverShowName
if (showName) {
name?.toStringArray()?.let { nameChars ->
val textSize = viewWidth / 8
namePaint.textSize = textSize
namePaint.strokeWidth = textSize / 8
if (BookCover.drawBookShadow) {
if (CoverConfig.coverShowShadow) {
namePaint.setShadowLayer(
4f,
0f,
@@ -141,12 +143,12 @@ class CoverImageView @JvmOverloads constructor(
}
nameChars.forEach { char ->
if (BookCover.drawBookStroke) {
if (CoverConfig.coverShowStroke) {
namePaint.color = Color.WHITE
namePaint.style = Paint.Style.STROKE
canvas.drawText(char, startX, startY, namePaint)
}
if (BookCover.coverDefaultColor)
if (CoverConfig.coverDefaultColor)
namePaint.color = context.themeColor(com.google.android.material.R.attr.colorSecondary)
else
namePaint.color = colorKey
@@ -164,14 +166,16 @@ class CoverImageView @JvmOverloads constructor(
}
}
if (BookCover.drawBookAuthor) {
val showAuthor =
if (isNightTheme) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor
if (showAuthor) {
author?.toStringArray()?.let { author ->
authorPaint.textSize = viewWidth / 10
authorPaint.strokeWidth = authorPaint.textSize / 5
startX = width * 0.8f
startY = viewHeight * 0.95f - author.size * authorPaint.textHeight
startY = maxOf(startY, viewHeight * 0.2f)
if (BookCover.drawBookShadow) {
if (CoverConfig.coverShowShadow) {
authorPaint.setShadowLayer(
4f,
0f,
@@ -183,12 +187,12 @@ class CoverImageView @JvmOverloads constructor(
}
author.forEach {
if (BookCover.drawBookStroke) {
if (CoverConfig.coverShowStroke) {
authorPaint.color = Color.WHITE
authorPaint.style = Paint.Style.STROKE
canvas.drawText(it, startX, startY, authorPaint)
}
if (BookCover.coverDefaultColor)
if (CoverConfig.coverDefaultColor)
authorPaint.color = context.themeColor(com.google.android.material.R.attr.colorSecondary)
else
authorPaint.color = colorKey