新的独属于MD3的雷霆主题管理与分享系统

This commit is contained in:
HapeLee
2026-05-12 19:12:32 +08:00
parent bc1d21f1f2
commit c748a5515a
11 changed files with 466 additions and 1081 deletions
@@ -1,109 +0,0 @@
package io.legado.app.help.config
import io.legado.app.ui.config.themeConfig.ThemeConfig
data class ThemeAppearanceConfig(
var enableDeepPersonalization: Boolean = false,
var themeColor: Int = 0,
var secondaryThemeColor: Int = 0,
var primaryTextColor: Int = 0,
var secondaryTextColor: Int = 0,
var backgroundColor: Int = 0,
var labelContainerColor: Int = 0,
var bookInfoInputColor: Int = 0,
var appFontPath: String? = null,
var enableContainerBorder: Boolean = false,
var containerBorderWidth: Float = 1f,
var containerBorderStyle: String = "solid",
var containerBorderDashWidth: Float = 4f,
var containerBorderColor: Int = 0,
var enableItemDivider: Boolean = true,
var itemDividerWidth: Float = 1f,
var itemDividerLength: Float = 80f,
var itemDividerColor: Int = 0,
var enableCustomTagColors: Boolean = false,
var customTagColorsJson: String? = null,
var enableBlur: Boolean = false,
var topBarBlurRadius: Int = 24,
var bottomBarBlurRadius: Int = 8,
var topBarBlurAlpha: Int = 73,
var bottomBarBlurAlpha: Int = 40,
var bottomBarLensRadius: Float = 24f
) {
val hasDeepColorOverrides: Boolean
get() = themeColor != 0 ||
secondaryThemeColor != 0 ||
primaryTextColor != 0 ||
secondaryTextColor != 0 ||
backgroundColor != 0 ||
labelContainerColor != 0
fun applyToThemeConfig(appFontPathOverride: String? = appFontPath) {
ThemeConfig.enableDeepPersonalization = enableDeepPersonalization
ThemeConfig.themeColor = themeColor
ThemeConfig.secondaryThemeColor = secondaryThemeColor
ThemeConfig.primaryTextColor = primaryTextColor
ThemeConfig.secondaryTextColor = secondaryTextColor
ThemeConfig.themeBackgroundColor = backgroundColor
ThemeConfig.labelContainerColor = labelContainerColor
ThemeConfig.bookInfoInputColor = bookInfoInputColor
ThemeConfig.appFontPath = appFontPathOverride
ThemeConfig.enableContainerBorder = enableContainerBorder
ThemeConfig.containerBorderWidth = containerBorderWidth
ThemeConfig.containerBorderStyle = containerBorderStyle.ifBlank { "solid" }
ThemeConfig.containerBorderDashWidth = containerBorderDashWidth
ThemeConfig.containerBorderColor = containerBorderColor
ThemeConfig.enableItemDivider = enableItemDivider
ThemeConfig.itemDividerWidth = itemDividerWidth
ThemeConfig.itemDividerLength = itemDividerLength
ThemeConfig.itemDividerColor = itemDividerColor
ThemeConfig.enableCustomTagColors = enableCustomTagColors
ThemeConfig.customTagColorsJson = ThemeAppearanceJson.convertTagColorsFromHex(customTagColorsJson)
ThemeConfig.enableBlur = enableBlur
ThemeConfig.topBarBlurRadius = topBarBlurRadius
ThemeConfig.bottomBarBlurRadius = bottomBarBlurRadius
ThemeConfig.topBarBlurAlpha = topBarBlurAlpha
ThemeConfig.bottomBarBlurAlpha = bottomBarBlurAlpha
ThemeConfig.bottomBarLensRadius = bottomBarLensRadius
}
companion object {
fun fromCurrent(): ThemeAppearanceConfig {
return ThemeAppearanceConfig(
enableDeepPersonalization = ThemeConfig.enableDeepPersonalization,
themeColor = ThemeConfig.themeColor,
secondaryThemeColor = ThemeConfig.secondaryThemeColor,
primaryTextColor = ThemeConfig.primaryTextColor,
secondaryTextColor = ThemeConfig.secondaryTextColor,
backgroundColor = ThemeConfig.themeBackgroundColor,
labelContainerColor = ThemeConfig.labelContainerColor,
bookInfoInputColor = ThemeConfig.bookInfoInputColor,
appFontPath = ThemeConfig.appFontPath,
enableContainerBorder = ThemeConfig.enableContainerBorder,
containerBorderWidth = ThemeConfig.containerBorderWidth,
containerBorderStyle = ThemeConfig.containerBorderStyle,
containerBorderDashWidth = ThemeConfig.containerBorderDashWidth,
containerBorderColor = ThemeConfig.containerBorderColor,
enableItemDivider = ThemeConfig.enableItemDivider,
itemDividerWidth = ThemeConfig.itemDividerWidth,
itemDividerLength = ThemeConfig.itemDividerLength,
itemDividerColor = ThemeConfig.itemDividerColor,
enableCustomTagColors = ThemeConfig.enableCustomTagColors,
customTagColorsJson = ThemeConfig.customTagColorsJson,
enableBlur = ThemeConfig.enableBlur,
topBarBlurRadius = ThemeConfig.topBarBlurRadius,
bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius,
topBarBlurAlpha = ThemeConfig.topBarBlurAlpha,
bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha,
bottomBarLensRadius = ThemeConfig.bottomBarLensRadius
)
}
}
}
@@ -1,159 +0,0 @@
package io.legado.app.help.config
import androidx.core.graphics.toColorInt
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.legado.app.ui.config.themeConfig.TagColorPair
import io.legado.app.utils.GSON
import io.legado.app.utils.getBoolean
import io.legado.app.utils.getFloat
import io.legado.app.utils.getString
object ThemeAppearanceJson {
fun writeTo(obj: JsonObject, src: ThemeAppearanceConfig) {
obj.addProperty("enableDeepPersonalization", src.enableDeepPersonalization)
obj.addProperty("themeColor", colorToHex(src.themeColor))
obj.addProperty("secondaryThemeColor", colorToHex(src.secondaryThemeColor))
obj.addProperty("primaryTextColor", colorToHex(src.primaryTextColor))
obj.addProperty("secondaryTextColor", colorToHex(src.secondaryTextColor))
obj.addProperty("backgroundColor", colorToHex(src.backgroundColor))
obj.addProperty("labelContainerColor", colorToHex(src.labelContainerColor))
obj.addProperty("bookInfoInputColor", colorToHex(src.bookInfoInputColor))
obj.addProperty("appFontPath", src.appFontPath)
obj.addProperty("enableContainerBorder", src.enableContainerBorder)
obj.addProperty("containerBorderWidth", src.containerBorderWidth)
obj.addProperty("containerBorderStyle", src.containerBorderStyle)
obj.addProperty("containerBorderDashWidth", src.containerBorderDashWidth)
obj.addProperty("containerBorderColor", colorToHex(src.containerBorderColor))
obj.addProperty("enableItemDivider", src.enableItemDivider)
obj.addProperty("itemDividerWidth", src.itemDividerWidth)
obj.addProperty("itemDividerLength", src.itemDividerLength)
obj.addProperty("itemDividerColor", colorToHex(src.itemDividerColor))
obj.addProperty("enableCustomTagColors", src.enableCustomTagColors)
obj.addProperty("customTagColorsJson", convertTagColorsToHex(src.customTagColorsJson))
obj.addProperty("enableBlur", src.enableBlur)
obj.addProperty("topBarBlurRadius", src.topBarBlurRadius)
obj.addProperty("bottomBarBlurRadius", src.bottomBarBlurRadius)
obj.addProperty("topBarBlurAlpha", src.topBarBlurAlpha)
obj.addProperty("bottomBarBlurAlpha", src.bottomBarBlurAlpha)
obj.addProperty("bottomBarLensRadius", src.bottomBarLensRadius)
}
fun readFrom(obj: JsonObject): ThemeAppearanceConfig {
val appearance = ThemeAppearanceConfig(
themeColor = readColor(obj, "themeColor", "md3Primary"),
secondaryThemeColor = readColor(obj, "secondaryThemeColor", "md3Secondary", "md3OnPrimary"),
primaryTextColor = readColor(obj, "primaryTextColor", "md3OnSurface"),
secondaryTextColor = readColor(obj, "secondaryTextColor", "md3OnPrimaryContainer"),
backgroundColor = readColor(obj, "backgroundColor", "md3Background"),
labelContainerColor = readColor(obj, "labelContainerColor", "md3SurfaceContainerLow"),
bookInfoInputColor = readColor(obj, "bookInfoInputColor"),
appFontPath = obj.getString("appFontPath", null),
enableContainerBorder = obj.getBoolean("enableContainerBorder", false),
containerBorderWidth = obj.getFloat("containerBorderWidth", 1f),
containerBorderStyle = obj.getString("containerBorderStyle", "solid") ?: "solid",
containerBorderDashWidth = obj.getFloat("containerBorderDashWidth", 4f),
containerBorderColor = readColor(obj, "containerBorderColor"),
enableItemDivider = obj.getBoolean("enableItemDivider", true),
itemDividerWidth = obj.getFloat("itemDividerWidth", 1f),
itemDividerLength = obj.getFloat("itemDividerLength", 80f),
itemDividerColor = readColor(obj, "itemDividerColor"),
enableCustomTagColors = obj.getBoolean("enableCustomTagColors", false),
customTagColorsJson = obj.getString("customTagColorsJson", null),
enableBlur = obj.getBoolean("enableBlur", false),
topBarBlurRadius = obj.getInt("topBarBlurRadius", 24),
bottomBarBlurRadius = obj.getInt("bottomBarBlurRadius", 8),
topBarBlurAlpha = obj.getInt("topBarBlurAlpha", 73),
bottomBarBlurAlpha = obj.getInt("bottomBarBlurAlpha", 40),
bottomBarLensRadius = obj.getFloat("bottomBarLensRadius", 24f)
)
appearance.enableDeepPersonalization = if (obj.has("enableDeepPersonalization")) {
obj.getBoolean("enableDeepPersonalization", false)
} else {
appearance.hasDeepColorOverrides
}
return appearance
}
fun convertTagColorsFromHex(json: String?): String? {
if (json.isNullOrBlank()) return json
return kotlin.runCatching {
val root = JsonParser.parseString(json)
if (root.isJsonArray) {
val arr = root.asJsonArray
val converted = arr.map { elem ->
val obj = elem.asJsonObject
val textColor = parseColorFromJson(obj.get("textColor"))
val bgColor = parseColorFromJson(obj.get("bgColor"))
"""{"textColor":$textColor,"bgColor":$bgColor}"""
}
"[\n ${converted.joinToString(",\n ")}\n]"
} else json
}.getOrNull() ?: json
}
fun readColor(obj: JsonObject, vararg names: String): Int {
for (name in names) {
val color = obj.getInt(name, Int.MIN_VALUE)
if (color != Int.MIN_VALUE) return color
}
return 0
}
fun colorToHex(color: Int): String = "#${String.format("%08X", color)}"
private fun convertTagColorsToHex(json: String?): String? {
if (json.isNullOrBlank()) return json
return kotlin.runCatching {
val colors = GSON.fromJson(json, Array<TagColorPair>::class.java)
val converted = colors.map { pair ->
val textHex = colorToHex(pair.textColor)
val bgHex = colorToHex(pair.bgColor)
"""{"textColor":"$textHex","bgColor":"$bgHex"}"""
}
"[\n ${converted.joinToString(",\n ")}\n]"
}.getOrNull() ?: json
}
private fun parseColorFromJson(elem: JsonElement?): Int {
if (elem == null || elem.isJsonNull) return 0
val prim = elem.asJsonPrimitive
return when {
prim.isNumber -> prim.asNumber.toInt()
prim.isString -> parseColorString(prim.asString)
else -> 0
}
}
private fun JsonObject.getInt(name: String, default: Int): Int {
val elem = get(name)
return when {
elem == null || elem.isJsonNull -> default
elem.isJsonPrimitive -> {
val prim = elem.asJsonPrimitive
when {
prim.isNumber -> prim.asNumber.toInt()
prim.isString -> parseColorString(prim.asString)
else -> default
}
}
else -> default
}
}
private fun parseColorString(colorStr: String): Int {
val text = colorStr.trim()
if (text.isEmpty()) return 0
return kotlin.runCatching {
if (text.startsWith("#")) {
text.substring(1).toLong(16).toInt()
} else {
text.toLong().toInt()
}
}.getOrElse {
kotlin.runCatching { text.toColorInt() }.getOrDefault(0)
}
}
}
@@ -58,6 +58,17 @@ object ThemeImportExport {
*/
fun saveCurrentAsTheme(name: String): SavedTheme {
val data = exportFromCurrent()
return saveThemeData(name, data)
}
/**
* 保存指定数据为新主题
*/
fun saveCurrentAsTheme(name: String, data: ThemeExportData): SavedTheme {
return saveThemeData(name, data)
}
private fun saveThemeData(name: String, data: ThemeExportData): SavedTheme {
val file = File(baseDir, "$name.json")
baseDir.mkdirs()
file.writeText(EXPORT_GSON.toJson(data))
@@ -1,327 +0,0 @@
package io.legado.app.help.config
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.FileUtils
import io.legado.app.utils.externalFiles
import splitties.init.appCtx
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
object ThemePackConfig {
private const val CONFIG_FILE = "theme.json"
private const val DIR_NAME = "theme_packs"
private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
private val PACK_GSON: Gson by lazy {
GsonBuilder()
.registerTypeAdapter(ThemePack::class.java, ThemePackSerializer())
.setPrettyPrinting()
.disableHtmlEscaping()
.create()
}
private val _packList = mutableListOf<ThemePack>()
val packList: List<ThemePack> get() = _packList
init {
loadAll()
}
private fun loadAll() {
_packList.clear()
baseDir.mkdirs()
baseDir.listFiles()?.forEach { dir ->
if (dir.isDirectory) {
val configFile = File(dir, CONFIG_FILE)
if (configFile.exists()) {
kotlin.runCatching {
val json = configFile.readText()
val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
if (pack.name.isNotEmpty()) {
pack.folderName = dir.name
_packList.add(pack)
}
}
}
}
}
}
fun reload() {
loadAll()
}
fun getPackDir(pack: ThemePack): File {
return File(baseDir, pack.folderName)
}
fun savePack(pack: ThemePack) {
val dir = getPackDir(pack)
dir.mkdirs()
val configFile = File(dir, CONFIG_FILE)
val json = PACK_GSON.toJson(pack)
FileUtils.createFileIfNotExist(configFile.absolutePath).writeText(json)
}
fun deletePack(pack: ThemePack) {
val dir = getPackDir(pack)
if (dir.exists()) {
dir.deleteRecursively()
}
_packList.remove(pack)
}
fun createFromCurrent(name: String): ThemePack {
val folderName = System.currentTimeMillis().toString()
val pack = ThemePack(
name = name,
folderName = folderName,
appearance = ThemeAppearanceConfig.fromCurrent(),
bgImageLight = ThemeConfig.bgImageLight,
bgImageDark = ThemeConfig.bgImageDark,
bgImageBlurring = ThemeConfig.bgImageBlurring,
bgImageNBlurring = ThemeConfig.bgImageNBlurring,
navIconBookshelf = MainConfig.navIconBookshelf,
navIconExplore = MainConfig.navIconExplore,
navIconRss = MainConfig.navIconRss,
navIconMy = MainConfig.navIconMy,
coverTextColor = CoverConfig.coverTextColor,
coverShadowColor = CoverConfig.coverShadowColor,
coverTextColorN = CoverConfig.coverTextColorN,
coverShadowColorN = CoverConfig.coverShadowColorN,
coverShowName = CoverConfig.coverShowName,
coverShowAuthor = CoverConfig.coverShowAuthor,
coverShowNameN = CoverConfig.coverShowNameN,
coverShowAuthorN = CoverConfig.coverShowAuthorN,
coverDefaultColor = CoverConfig.coverDefaultColor,
coverShowShadow = CoverConfig.coverShowShadow,
coverShowStroke = CoverConfig.coverShowStroke,
coverInfoOrientation = CoverConfig.coverInfoOrientation,
defaultCover = CoverConfig.defaultCover,
defaultCoverDark = CoverConfig.defaultCoverDark
)
val dir = File(baseDir, folderName)
dir.mkdirs()
copyResourceFile(pack.appearance.appFontPath, dir, "app_font")
copyResourceFile(pack.bgImageLight, dir, "bg_light")
copyResourceFile(pack.bgImageDark, dir, "bg_dark")
copyResourceFile(pack.navIconBookshelf, dir, "nav_bookshelf")
copyResourceFile(pack.navIconExplore, dir, "nav_explore")
copyResourceFile(pack.navIconRss, dir, "nav_rss")
copyResourceFile(pack.navIconMy, dir, "nav_my")
copyCoverImages(pack.defaultCover, dir, "cover_day")
copyCoverImages(pack.defaultCoverDark, dir, "cover_night")
savePack(pack)
_packList.add(pack)
return pack
}
fun applyPack(pack: ThemePack) {
val dir = getPackDir(pack)
val restoredAppearance = pack.appearance.copy(
appFontPath = restoreResourcePath(dir, "app_font", pack.appearance.appFontPath)
)
restoredAppearance.applyToThemeConfig()
ThemeConfig.bgImageLight = restoreResourcePath(dir, "bg_light", pack.bgImageLight)
ThemeConfig.bgImageDark = restoreResourcePath(dir, "bg_dark", pack.bgImageDark)
ThemeConfig.bgImageBlurring = pack.bgImageBlurring
ThemeConfig.bgImageNBlurring = pack.bgImageNBlurring
MainConfig.navIconBookshelf = restoreResourcePath(dir, "nav_bookshelf", pack.navIconBookshelf) ?: ""
MainConfig.navIconExplore = restoreResourcePath(dir, "nav_explore", pack.navIconExplore) ?: ""
MainConfig.navIconRss = restoreResourcePath(dir, "nav_rss", pack.navIconRss) ?: ""
MainConfig.navIconMy = restoreResourcePath(dir, "nav_my", pack.navIconMy) ?: ""
CoverConfig.coverTextColor = pack.coverTextColor
CoverConfig.coverShadowColor = pack.coverShadowColor
CoverConfig.coverTextColorN = pack.coverTextColorN
CoverConfig.coverShadowColorN = pack.coverShadowColorN
CoverConfig.coverShowName = pack.coverShowName
CoverConfig.coverShowAuthor = pack.coverShowAuthor
CoverConfig.coverShowNameN = pack.coverShowNameN
CoverConfig.coverShowAuthorN = pack.coverShowAuthorN
CoverConfig.coverDefaultColor = pack.coverDefaultColor
CoverConfig.coverShowShadow = pack.coverShowShadow
CoverConfig.coverShowStroke = pack.coverShowStroke
CoverConfig.coverInfoOrientation = pack.coverInfoOrientation
CoverConfig.defaultCover = restoreCoverImages(dir, "cover_day", pack.defaultCover)
CoverConfig.defaultCoverDark = restoreCoverImages(dir, "cover_night", pack.defaultCoverDark)
}
fun exportToZip(pack: ThemePack, destFile: File) {
val dir = getPackDir(pack)
if (!dir.exists()) return
ZipOutputStream(FileOutputStream(destFile)).use { zos ->
dir.walkTopDown().forEach { file ->
if (file.isFile) {
val entryName = file.relativeTo(dir).path.replace('\\', '/')
zos.putNextEntry(ZipEntry(entryName))
FileInputStream(file).use { fis ->
fis.copyTo(zos)
}
zos.closeEntry()
}
}
}
}
fun importFromZip(zipFile: File): ThemePack? {
val folderName = System.currentTimeMillis().toString()
val dir = File(baseDir, folderName)
dir.mkdirs()
return kotlin.runCatching {
ZipInputStream(FileInputStream(zipFile)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
val outFile = resolveZipOutputFile(dir, entry)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { fos ->
zis.copyTo(fos)
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
val configFile = File(dir, CONFIG_FILE)
if (!configFile.exists()) {
dir.deleteRecursively()
return null
}
val json = configFile.readText()
val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
pack.folderName = folderName
_packList.add(pack)
pack
}.onFailure {
dir.deleteRecursively()
}.getOrNull()
}
private fun resolveZipOutputFile(dir: File, entry: ZipEntry): File {
val outFile = File(dir, entry.name).canonicalFile
val canonicalDir = dir.canonicalFile
if (outFile.path == canonicalDir.path ||
outFile.path.startsWith(canonicalDir.path + File.separator)
) {
return outFile
}
throw IllegalArgumentException("Invalid theme pack entry: ${entry.name}")
}
private fun copyResourceFile(sourcePath: String?, destDir: File, prefix: String) {
if (sourcePath.isNullOrEmpty()) return
val sourceUri = Uri.parse(sourcePath)
val sourceName = sourceUri.lastPathSegment ?: sourcePath
val ext = sourceName.substringAfterLast(".", "dat").ifBlank { "dat" }
val destFile = File(destDir, "$prefix.$ext")
if (sourceUri.scheme == "content") {
appCtx.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
return
}
val sourceFile = File(sourcePath)
if (!sourceFile.exists()) return
sourceFile.copyTo(destFile, overwrite = true)
}
private fun restoreResourcePath(packDir: File, prefix: String, originalPath: String?): String? {
if (originalPath.isNullOrEmpty()) return null
val candidates = packDir.listFiles { file ->
file.name.startsWith(prefix) && file.isFile
}
if (candidates.isNullOrEmpty()) return null
val sourceFile = candidates.first()
val targetDir = File(appCtx.filesDir, "theme_pack_resources")
targetDir.mkdirs()
val targetFile = File(targetDir, "${System.currentTimeMillis()}_${sourceFile.name}")
sourceFile.copyTo(targetFile, overwrite = true)
return targetFile.absolutePath
}
private fun copyCoverImages(coverPaths: String, destDir: File, prefix: String) {
val paths = coverPaths.split(",").filter { it.isNotBlank() }
paths.forEachIndexed { index, path ->
copyResourceFile(path, destDir, "${prefix}_$index")
}
}
private fun restoreCoverImages(packDir: File, prefix: String, originalPaths: String): String {
if (originalPaths.isEmpty()) return ""
val candidates = packDir.listFiles { file ->
file.name.startsWith(prefix + "_") && file.isFile
}
if (candidates.isNullOrEmpty()) return originalPaths
val sortedCandidates = candidates.sortedBy { file ->
file.name.substringAfterLast("_").substringBefore(".").toIntOrNull() ?: 0
}
val coverDir = File(appCtx.externalFiles, "covers")
coverDir.mkdirs()
val restoredPaths = mutableListOf<String>()
sortedCandidates.forEach { sourceFile ->
val targetFile = File(coverDir, sourceFile.name)
sourceFile.copyTo(targetFile, overwrite = true)
restoredPaths.add(targetFile.absolutePath)
}
return if (restoredPaths.isEmpty()) originalPaths else restoredPaths.joinToString(",")
}
data class ThemePack(
var name: String = "",
@Transient
var folderName: String = "",
var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig(),
var bgImageLight: String? = null,
var bgImageDark: String? = null,
var bgImageBlurring: Int = 0,
var bgImageNBlurring: Int = 0,
var navIconBookshelf: String = "",
var navIconExplore: String = "",
var navIconRss: String = "",
var navIconMy: String = "",
var coverTextColor: Int = -16777216,
var coverShadowColor: Int = -16777216,
var coverTextColorN: Int = -1,
var coverShadowColorN: Int = -1,
var coverShowName: Boolean = true,
var coverShowAuthor: Boolean = true,
var coverShowNameN: Boolean = true,
var coverShowAuthorN: Boolean = true,
var coverDefaultColor: Boolean = true,
var coverShowShadow: Boolean = false,
var coverShowStroke: Boolean = true,
var coverInfoOrientation: String = "0",
var defaultCover: String = "",
var defaultCoverDark: String = ""
) {
val themeColor: Int get() = appearance.themeColor
val appFontPath: String? get() = appearance.appFontPath
}
}
@@ -1,83 +0,0 @@
package io.legado.app.help.config
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import io.legado.app.utils.getBoolean
import io.legado.app.utils.getInt
import io.legado.app.utils.getString
import java.lang.reflect.Type
class ThemePackSerializer : JsonSerializer<ThemePackConfig.ThemePack>,
JsonDeserializer<ThemePackConfig.ThemePack> {
override fun serialize(
src: ThemePackConfig.ThemePack,
typeOfSrc: Type,
context: JsonSerializationContext
): JsonElement {
val obj = JsonObject()
obj.addProperty("name", src.name)
ThemeAppearanceJson.writeTo(obj, src.appearance)
obj.addProperty("bgImageLight", src.bgImageLight)
obj.addProperty("bgImageDark", src.bgImageDark)
obj.addProperty("bgImageBlurring", src.bgImageBlurring)
obj.addProperty("bgImageNBlurring", src.bgImageNBlurring)
obj.addProperty("navIconBookshelf", src.navIconBookshelf)
obj.addProperty("navIconExplore", src.navIconExplore)
obj.addProperty("navIconRss", src.navIconRss)
obj.addProperty("navIconMy", src.navIconMy)
obj.addProperty("coverTextColor", ThemeAppearanceJson.colorToHex(src.coverTextColor))
obj.addProperty("coverShadowColor", ThemeAppearanceJson.colorToHex(src.coverShadowColor))
obj.addProperty("coverTextColorN", ThemeAppearanceJson.colorToHex(src.coverTextColorN))
obj.addProperty("coverShadowColorN", ThemeAppearanceJson.colorToHex(src.coverShadowColorN))
obj.addProperty("coverShowName", src.coverShowName)
obj.addProperty("coverShowAuthor", src.coverShowAuthor)
obj.addProperty("coverShowNameN", src.coverShowNameN)
obj.addProperty("coverShowAuthorN", src.coverShowAuthorN)
obj.addProperty("coverDefaultColor", src.coverDefaultColor)
obj.addProperty("coverShowShadow", src.coverShowShadow)
obj.addProperty("coverShowStroke", src.coverShowStroke)
obj.addProperty("coverInfoOrientation", src.coverInfoOrientation)
obj.addProperty("defaultCover", src.defaultCover)
obj.addProperty("defaultCoverDark", src.defaultCoverDark)
return obj
}
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): ThemePackConfig.ThemePack {
val obj = json.asJsonObject
return ThemePackConfig.ThemePack(
name = obj.getString("name", "") ?: "",
appearance = ThemeAppearanceJson.readFrom(obj),
bgImageLight = obj.getString("bgImageLight", null),
bgImageDark = obj.getString("bgImageDark", null),
bgImageBlurring = obj.getInt("bgImageBlurring", 0),
bgImageNBlurring = obj.getInt("bgImageNBlurring", 0),
navIconBookshelf = obj.getString("navIconBookshelf", "") ?: "",
navIconExplore = obj.getString("navIconExplore", "") ?: "",
navIconRss = obj.getString("navIconRss", "") ?: "",
navIconMy = obj.getString("navIconMy", "") ?: "",
coverTextColor = ThemeAppearanceJson.readColor(obj, "coverTextColor").takeIf { it != 0 } ?: -16777216,
coverShadowColor = ThemeAppearanceJson.readColor(obj, "coverShadowColor").takeIf { it != 0 } ?: -16777216,
coverTextColorN = ThemeAppearanceJson.readColor(obj, "coverTextColorN").takeIf { it != 0 } ?: -1,
coverShadowColorN = ThemeAppearanceJson.readColor(obj, "coverShadowColorN").takeIf { it != 0 } ?: -1,
coverShowName = obj.getBoolean("coverShowName", true),
coverShowAuthor = obj.getBoolean("coverShowAuthor", true),
coverShowNameN = obj.getBoolean("coverShowNameN", true),
coverShowAuthorN = obj.getBoolean("coverShowAuthorN", true),
coverDefaultColor = obj.getBoolean("coverDefaultColor", true),
coverShowShadow = obj.getBoolean("coverShowShadow", false),
coverShowStroke = obj.getBoolean("coverShowStroke", true),
coverInfoOrientation = obj.getString("coverInfoOrientation", "0") ?: "0",
defaultCover = obj.getString("defaultCover", "") ?: "",
defaultCoverDark = obj.getString("defaultCoverDark", "") ?: ""
)
}
}
@@ -172,6 +172,22 @@ object ThemeConfig {
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
var navExtended by prefDelegate("navExtended", false)
var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "")
var navIconExplore by prefDelegate(PreferKey.navIconExplore, "")
var navIconRss by prefDelegate(PreferKey.navIconRss, "")
var navIconMy by prefDelegate(PreferKey.navIconMy, "")
fun hasImageBg(isDark: Boolean): Boolean =
!(if (isDark) bgImageDark else bgImageLight).isNullOrBlank()
@@ -79,7 +79,6 @@ import com.google.android.material.color.DynamicColorsOptions
import io.legado.app.R
import io.legado.app.base.AppContextWrapper
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.constant.EventBus
import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.loadFontFiles
@@ -124,6 +123,7 @@ import top.yukonga.miuix.kmp.basic.Text as MiuixText
fun ThemeConfigScreen(
onBackClick: () -> Unit,
onNavigateToCustomTheme: () -> Unit,
onNavigateToThemeManage: () -> Unit,
viewModel: ThemeConfigViewModel = koinViewModel()
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -412,7 +412,7 @@ fun ThemeConfigScreen(
)
AnimatedVisibility(visible = ThemeConfig.useFloatingBottomBar) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
SwitchSettingItem(
title = stringResource(R.string.floating_bottom_bar_liquid_glass),
@@ -716,10 +716,10 @@ fun ThemeConfigScreen(
item {
SplicedColumnGroup(title = "导航栏图标设置") {
val customCount = listOf(
MainConfig.navIconBookshelf,
MainConfig.navIconExplore,
MainConfig.navIconRss,
MainConfig.navIconMy
ThemeConfig.navIconBookshelf,
ThemeConfig.navIconExplore,
ThemeConfig.navIconRss,
ThemeConfig.navIconMy
).count { it.isNotEmpty() }
ClickableSettingItem(
title = "导航栏图标",
@@ -728,6 +728,17 @@ fun ThemeConfigScreen(
)
}
}
// Theme management
item {
SplicedColumnGroup(title = "主题管理") {
ClickableSettingItem(
title = "主题管理",
description = "保存、导入、导出主题配置",
onClick = onNavigateToThemeManage
)
}
}
}
}
@@ -0,0 +1,369 @@
package io.legado.app.ui.config.themeManage
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.help.config.ThemeExportData
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText
@Composable
fun EditThemeSheet(
show: Boolean,
themeData: ThemeExportData?,
themeName: String,
onDismissRequest: () -> Unit,
onSave: (newName: String, newData: ThemeExportData) -> Unit
) {
if (!show || themeData == null) return
var data by remember(themeData) { mutableStateOf(themeData) }
var name by remember(themeName) { mutableStateOf(themeName) }
var showColorPicker by remember { mutableStateOf(false) }
var currentColorKey by remember { mutableStateOf("") }
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = "编辑主题",
endAction = {
MediumIconButton(
onClick = {
if (name.isNotBlank()) {
onSave(name, data)
}
},
imageVector = Icons.Default.Done,
contentDescription = "Save"
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 32.dp)
) {
// Name
AppTextField(
value = name,
onValueChange = { name = it },
placeholder = { },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Basic settings
SectionTitle("基础设置")
CompactDropdownSettingItem(
title = "主题模式",
selectedValue = data.themeMode,
displayEntries = stringArrayResource(R.array.theme_mode),
entryValues = stringArrayResource(R.array.theme_mode_v),
onValueChange = { data = data.copy(themeMode = it) }
)
CompactDropdownSettingItem(
title = "调色板风格",
selectedValue = data.paletteStyle,
displayEntries = stringArrayResource(R.array.paletteStyle),
entryValues = stringArrayResource(R.array.paletteStyle_value),
onValueChange = { data = data.copy(paletteStyle = it) }
)
CompactDropdownSettingItem(
title = "Material 版本",
selectedValue = data.materialVersion,
displayEntries = stringArrayResource(R.array.materialVersion),
entryValues = stringArrayResource(R.array.materialVersion_value),
onValueChange = { data = data.copy(materialVersion = it) }
)
CompactDropdownSettingItem(
title = "对比度偏好",
selectedValue = data.customContrast,
displayEntries = stringArrayResource(R.array.customContrast),
entryValues = stringArrayResource(R.array.customContrast_value),
onValueChange = { data = data.copy(customContrast = it) }
)
Spacer(modifier = Modifier.height(8.dp))
// Colors
SectionTitle("颜色设置")
CompactSwitchSettingItem(
title = "使用色板生成颜色",
checked = !data.enableDeepPersonalization,
onCheckedChange = { data = data.copy(enableDeepPersonalization = !it) }
)
if (data.enableDeepPersonalization) {
ColorItem("主题色", data.themeColor) {
currentColorKey = "themeColor"; showColorPicker = true
}
ColorItem("次要主题色", data.secondaryThemeColor) {
currentColorKey = "secondaryThemeColor"; showColorPicker = true
}
ColorItem("主要字体色", data.primaryTextColor) {
currentColorKey = "primaryTextColor"; showColorPicker = true
}
ColorItem("次要字体色", data.secondaryTextColor) {
currentColorKey = "secondaryTextColor"; showColorPicker = true
}
ColorItem("背景色", data.themeBackgroundColor) {
currentColorKey = "themeBackgroundColor"; showColorPicker = true
}
ColorItem("标签容器色", data.labelContainerColor) {
currentColorKey = "labelContainerColor"; showColorPicker = true
}
} else {
ColorItem("日间种子色", data.cPrimary) {
currentColorKey = "cPrimary"; showColorPicker = true
}
ColorItem("夜间种子色", data.cNPrimary) {
currentColorKey = "cNPrimary"; showColorPicker = true
}
}
Spacer(modifier = Modifier.height(8.dp))
// Interface layout
SectionTitle("界面布局")
CompactSwitchSettingItem(
title = "发现",
checked = data.showDiscovery,
onCheckedChange = { data = data.copy(showDiscovery = it) }
)
CompactSwitchSettingItem(
title = "RSS",
checked = data.showRss,
onCheckedChange = { data = data.copy(showRss = it) }
)
CompactSwitchSettingItem(
title = "显示底栏",
checked = data.showBottomView,
onCheckedChange = { data = data.copy(showBottomView = it) }
)
CompactSwitchSettingItem(
title = "浮动底栏",
checked = data.useFloatingBottomBar,
onCheckedChange = { data = data.copy(useFloatingBottomBar = it) }
)
CompactSwitchSettingItem(
title = "状态栏",
checked = data.showStatusBar,
onCheckedChange = { data = data.copy(showStatusBar = it) }
)
CompactSwitchSettingItem(
title = "翻页动画",
checked = data.swipeAnimation,
onCheckedChange = { data = data.copy(swipeAnimation = it) }
)
CompactDropdownSettingItem(
title = "平板模式",
selectedValue = data.tabletInterface,
displayEntries = stringArrayResource(R.array.tabletInterface),
entryValues = stringArrayResource(R.array.tabletInterface_value),
onValueChange = { data = data.copy(tabletInterface = it) }
)
CompactDropdownSettingItem(
title = "标签显示",
selectedValue = data.labelVisibilityMode,
displayEntries = stringArrayResource(R.array.label_vis_mode),
entryValues = stringArrayResource(R.array.label_vis_mode_value),
onValueChange = { data = data.copy(labelVisibilityMode = it) }
)
CompactDropdownSettingItem(
title = "默认主页",
selectedValue = data.defaultHomePage,
displayEntries = stringArrayResource(R.array.default_home_page),
entryValues = stringArrayResource(R.array.default_home_page_value),
onValueChange = { data = data.copy(defaultHomePage = it) }
)
Spacer(modifier = Modifier.height(8.dp))
// Blur
SectionTitle("模糊效果")
CompactSwitchSettingItem(
title = "启用模糊",
checked = data.enableBlur,
onCheckedChange = { data = data.copy(enableBlur = it) }
)
if (data.enableBlur) {
CompactSliderSettingItem(
title = "顶栏模糊半径",
value = data.topBarBlurRadius.toFloat(),
valueRange = 1f..60f,
onValueChange = { data = data.copy(topBarBlurRadius = it.toInt()) }
)
CompactSliderSettingItem(
title = "底栏模糊半径",
value = data.bottomBarBlurRadius.toFloat(),
valueRange = 1f..60f,
onValueChange = { data = data.copy(bottomBarBlurRadius = it.toInt()) }
)
CompactSliderSettingItem(
title = "顶栏透明度",
value = data.topBarBlurAlpha.toFloat(),
valueRange = 0f..255f,
onValueChange = { data = data.copy(topBarBlurAlpha = it.toInt()) }
)
CompactSliderSettingItem(
title = "底栏透明度",
value = data.bottomBarBlurAlpha.toFloat(),
valueRange = 0f..255f,
onValueChange = { data = data.copy(bottomBarBlurAlpha = it.toInt()) }
)
}
Spacer(modifier = Modifier.height(8.dp))
// Opacity
SectionTitle("透明度")
CompactSliderSettingItem(
title = "顶栏透明度",
value = data.topBarOpacity.toFloat(),
valueRange = 0f..100f,
onValueChange = { data = data.copy(topBarOpacity = it.toInt()) }
)
CompactSliderSettingItem(
title = "底栏透明度",
value = data.bottomBarOpacity.toFloat(),
valueRange = 0f..100f,
onValueChange = { data = data.copy(bottomBarOpacity = it.toInt()) }
)
Spacer(modifier = Modifier.height(8.dp))
// Container
SectionTitle("容器设置")
CompactSliderSettingItem(
title = "容器不透明度",
value = data.containerOpacity.toFloat(),
valueRange = 0f..100f,
onValueChange = { data = data.copy(containerOpacity = it.toInt()) }
)
CompactSwitchSettingItem(
title = "容器边框",
checked = data.enableContainerBorder,
onCheckedChange = { data = data.copy(enableContainerBorder = it) }
)
Spacer(modifier = Modifier.height(8.dp))
// Other
SectionTitle("其他")
CompactSwitchSettingItem(
title = "纯黑模式",
checked = data.isPureBlack,
onCheckedChange = { data = data.copy(isPureBlack = it) }
)
CompactSwitchSettingItem(
title = "弹性顶栏",
checked = data.useFlexibleTopAppBar,
onCheckedChange = { data = data.copy(useFlexibleTopAppBar = it) }
)
}
}
ColorPickerSheet(
show = showColorPicker,
initialColor = when (currentColorKey) {
"themeColor" -> data.themeColor
"secondaryThemeColor" -> data.secondaryThemeColor
"primaryTextColor" -> data.primaryTextColor
"secondaryTextColor" -> data.secondaryTextColor
"themeBackgroundColor" -> data.themeBackgroundColor
"labelContainerColor" -> data.labelContainerColor
"cPrimary" -> data.cPrimary
"cNPrimary" -> data.cNPrimary
else -> 0
},
onDismissRequest = { showColorPicker = false },
onColorSelected = { color ->
data = when (currentColorKey) {
"themeColor" -> data.copy(themeColor = color)
"secondaryThemeColor" -> data.copy(secondaryThemeColor = color)
"primaryTextColor" -> data.copy(primaryTextColor = color)
"secondaryTextColor" -> data.copy(secondaryTextColor = color)
"themeBackgroundColor" -> data.copy(themeBackgroundColor = color)
"labelContainerColor" -> data.copy(labelContainerColor = color)
"cPrimary" -> data.copy(cPrimary = color)
"cNPrimary" -> data.copy(cNPrimary = color)
else -> data
}
showColorPicker = false
}
)
}
@Composable
private fun SectionTitle(text: String) {
AppText(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun ColorItem(title: String, colorValue: Int, onClick: () -> Unit) {
CompactClickableSettingItem(
title = title,
description = if (colorValue != 0) {
"#${Integer.toHexString(colorValue).uppercase()}"
} else {
null
},
onClick = onClick,
trailingContent = {
if (colorValue != 0) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(Color(colorValue))
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape
)
)
}
}
)
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -68,6 +69,7 @@ fun ThemeManageScreen(
var deleteTarget by remember { mutableStateOf<SavedTheme?>(null) }
var applyTarget by remember { mutableStateOf<SavedTheme?>(null) }
var exportTarget by remember { mutableStateOf<SavedTheme?>(null) }
var editTarget by remember { mutableStateOf<SavedTheme?>(null) }
var showRestartDialog by remember { mutableStateOf(false) }
var savedThemesVersion by remember { mutableIntStateOf(0) }
val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
@@ -166,6 +168,7 @@ fun ThemeManageScreen(
SavedThemeItem(
theme = theme,
onApply = { applyTarget = theme },
onEdit = { editTarget = theme },
onExport = {
exportTarget = theme
exportLauncher.launch("${theme.name}.json")
@@ -257,12 +260,29 @@ fun ThemeManageScreen(
onDismiss = { deleteTarget = null },
text = "确定删除主题「${deleteTarget?.name}」?此操作不可恢复。"
)
// Edit theme sheet
EditThemeSheet(
show = editTarget != null,
themeData = editTarget?.data,
themeName = editTarget?.name ?: "",
onDismissRequest = { editTarget = null },
onSave = { newName, newData ->
editTarget?.let { old ->
ThemeImportExport.deleteSavedTheme(old)
}
ThemeImportExport.saveCurrentAsTheme(newName, newData)
savedThemesVersion++
editTarget = null
}
)
}
@Composable
private fun SavedThemeItem(
theme: SavedTheme,
onApply: () -> Unit,
onEdit: () -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit
) {
@@ -320,6 +340,13 @@ private fun SavedThemeItem(
)
}
IconButton(onClick = onEdit) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "编辑",
modifier = Modifier.size(20.dp)
)
}
IconButton(onClick = onExport) {
Icon(
imageVector = Icons.Default.Share,
@@ -1,372 +0,0 @@
package io.legado.app.ui.config.themePack
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.legado.app.R
import io.legado.app.help.config.ThemePackConfig
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThemePackScreen(
onBackClick: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var listVersion by remember { mutableIntStateOf(0) }
var showAddDialog by remember { mutableStateOf(false) }
var newThemeName by remember { mutableStateOf("") }
var deleteTarget by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
var applyTarget by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
var exportingPack by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
val packList = remember(listVersion) { ThemePackConfig.packList.toList() }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
uri?.let {
val pack = exportingPack ?: return@let
exportingPack = null
scope.launch(Dispatchers.IO) {
kotlin.runCatching {
val tempFile = File(context.cacheDir, "theme_export_${pack.name}.zip")
ThemePackConfig.exportToZip(pack, tempFile)
context.contentResolver.openOutputStream(uri)?.use { os ->
tempFile.inputStream().use { it.copyTo(os) }
}
tempFile.delete()
withContext(Dispatchers.Main) {
context.toastOnUi("导出成功")
}
}.onFailure {
withContext(Dispatchers.Main) {
context.toastOnUi("导出失败: ${it.message}")
}
}
}
}
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
scope.launch(Dispatchers.IO) {
kotlin.runCatching {
val tempFile = File(context.cacheDir, "theme_import_${System.currentTimeMillis()}.zip")
context.contentResolver.openInputStream(it)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
val pack = ThemePackConfig.importFromZip(tempFile)
tempFile.delete()
if (pack != null) {
listVersion++
withContext(Dispatchers.Main) {
context.toastOnUi("导入成功: ${pack.name}")
}
} else {
withContext(Dispatchers.Main) {
context.toastOnUi("导入失败: 无效的主题包")
}
}
}.onFailure {
withContext(Dispatchers.Main) {
context.toastOnUi("导入失败: ${it.message}")
}
}
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.theme_pack),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp
)
) {
item {
SplicedColumnGroup {
ClickableSettingItem(
title = "新增主题",
description = "保存当前设置为新主题",
onClick = {
newThemeName = ""
showAddDialog = true
}
)
ClickableSettingItem(
title = "导入主题",
description = "从zip包导入主题",
onClick = {
importLauncher.launch(arrayOf("application/zip", "application/octet-stream"))
}
)
}
}
if (packList.isNotEmpty()) {
item {
AppText(
text = "主题列表",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp)
)
}
items(packList, key = { it.folderName }) { pack ->
ThemePackItem(
pack = pack,
onApply = { applyTarget = pack },
onExport = { exportingPack = pack },
onDelete = { deleteTarget = pack }
)
}
}
}
}
AppAlertDialog(
data = if (showAddDialog) "add" else null,
onDismissRequest = { showAddDialog = false },
title = "新增主题",
confirmText = "保存",
onConfirm = {
if (newThemeName.isNotBlank()) {
scope.launch(Dispatchers.IO) {
ThemePackConfig.createFromCurrent(newThemeName)
listVersion++
}
showAddDialog = false
}
},
dismissText = "取消",
onDismiss = { showAddDialog = false },
content = {
io.legado.app.ui.widget.components.AppTextField(
value = newThemeName,
onValueChange = { newThemeName = it },
placeholder = { AppText(text = "请输入主题名称") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
)
AppAlertDialog(
data = applyTarget,
onDismissRequest = { applyTarget = null },
title = "应用主题",
confirmText = "应用",
onConfirm = { pack ->
scope.launch(Dispatchers.IO) {
ThemePackConfig.applyPack(pack)
withContext(Dispatchers.Main) {
context.toastOnUi("主题已应用,请重启生效")
}
}
applyTarget = null
},
dismissText = "取消",
onDismiss = { applyTarget = null },
text = "确定应用主题「${applyTarget?.name}」?应用后需要重启才能完全生效。"
)
AppAlertDialog(
data = deleteTarget,
onDismissRequest = { deleteTarget = null },
title = "删除主题",
confirmText = "删除",
onConfirm = { pack ->
scope.launch(Dispatchers.IO) {
ThemePackConfig.deletePack(pack)
listVersion++
}
deleteTarget = null
},
dismissText = "取消",
onDismiss = { deleteTarget = null },
text = "确定删除主题「${deleteTarget?.name}」?此操作不可恢复。"
)
LaunchedEffect(exportingPack) {
exportingPack?.let { pack ->
kotlin.runCatching {
exportLauncher.launch("${pack.name}.zip")
}.onFailure {
exportingPack = null
context.toastOnUi("导出失败: ${it.message}")
}
}
}
}
@Composable
private fun ThemePackItem(
pack: ThemePackConfig.ThemePack,
onApply: () -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit
) {
GlassCard(
onClick = onApply,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
cornerRadius = 20.dp
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
val packDir = remember { ThemePackConfig.getPackDir(pack) }
val bgFile = remember(pack.folderName) {
packDir.listFiles()?.firstOrNull {
it.isFile && (it.name.startsWith("bg_light") || it.name.startsWith("bg_dark"))
}
}
val coverFile = remember(pack.folderName) {
packDir.listFiles()?.firstOrNull {
it.isFile && it.name.startsWith("cover_day_")
}
}
val previewFile = bgFile ?: coverFile
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.background(
if (pack.themeColor != 0) Color(pack.themeColor)
else MaterialTheme.colorScheme.primary
)
) {
if (previewFile != null) {
AsyncImage(
model = previewFile,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
AppText(
text = pack.name,
style = MaterialTheme.typography.bodyLarge
)
val hasCustomIcon = pack.navIconBookshelf.isNotEmpty() ||
pack.navIconExplore.isNotEmpty() ||
pack.navIconRss.isNotEmpty() ||
pack.navIconMy.isNotEmpty()
val hasCover = pack.defaultCover.isNotEmpty() || pack.defaultCoverDark.isNotEmpty()
val features = mutableListOf<String>()
if (pack.appFontPath != null) features.add("应用字体")
if (pack.bgImageLight != null || pack.bgImageDark != null) features.add("应用背景")
if (hasCover) features.add("封面图片")
if (hasCustomIcon) features.add("图标")
if (features.isNotEmpty()) {
AppText(
text = features.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = onExport) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "导出",
modifier = Modifier.size(20.dp)
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
@@ -67,7 +67,6 @@ import dev.chrisbanes.haze.hazeSource
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import io.legado.app.R
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -114,6 +113,7 @@ fun MainScreen(
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
onNavigateToReadRecord: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
@@ -150,6 +150,7 @@ fun MainScreen(
}
MainEffect.ExitApp -> (context as? ComponentActivity)?.finish()
MainEffect.NavigateToReadRecord -> onNavigateToReadRecord()
}
}
}
@@ -281,10 +282,10 @@ fun MainScreen(
},
label = if (labelVisibilityMode != "unlabeled") {
val hasCustomIcon = when (destination) {
MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> ThemeConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> ThemeConfig.navIconRss.isNotEmpty()
MainDestination.My -> ThemeConfig.navIconMy.isNotEmpty()
}
if (hasCustomIcon) null else {{ AppText(stringResource(destination.labelId)) }}
} else null
@@ -301,10 +302,10 @@ fun MainScreen(
destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index
val customIconPath = when (destination) {
MainDestination.Bookshelf -> MainConfig.navIconBookshelf
MainDestination.Explore -> MainConfig.navIconExplore
MainDestination.Rss -> MainConfig.navIconRss
MainDestination.My -> MainConfig.navIconMy
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf
MainDestination.Explore -> ThemeConfig.navIconExplore
MainDestination.Rss -> ThemeConfig.navIconRss
MainDestination.My -> ThemeConfig.navIconMy
}
AppNavigationBarItem(
selected = selected,
@@ -390,10 +391,10 @@ fun MainScreen(
MainDestination.My -> MyScreen(
onOpenSettings = onOpenSettings,
onNavigate = { event ->
if (event == PrefClickEvent.OpenBookCacheManage) {
onNavigateToBookCacheManage()
} else {
viewModel.onPrefClickEvent(event)
when (event) {
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
PrefClickEvent.OpenReadRecord -> onNavigateToReadRecord()
else -> viewModel.onPrefClickEvent(event)
}
}
)
@@ -432,20 +433,20 @@ fun MainScreen(
isBlurEnabled = useLiquidGlass,
hasCustomIcons = destinations.any { dest ->
when (dest) {
MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> ThemeConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> ThemeConfig.navIconRss.isNotEmpty()
MainDestination.My -> ThemeConfig.navIconMy.isNotEmpty()
}
}
) {
destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index
val hasCustomIcon = when (destination) {
MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf.isNotEmpty()
MainDestination.Explore -> ThemeConfig.navIconExplore.isNotEmpty()
MainDestination.Rss -> ThemeConfig.navIconRss.isNotEmpty()
MainDestination.My -> ThemeConfig.navIconMy.isNotEmpty()
}
FloatingBottomBarItem(
onClick = {
@@ -522,10 +523,10 @@ private fun NavigationIcon(
modifier: Modifier = Modifier
) {
val customIconPath = when (destination) {
MainDestination.Bookshelf -> MainConfig.navIconBookshelf
MainDestination.Explore -> MainConfig.navIconExplore
MainDestination.Rss -> MainConfig.navIconRss
MainDestination.My -> MainConfig.navIconMy
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf
MainDestination.Explore -> ThemeConfig.navIconExplore
MainDestination.Rss -> ThemeConfig.navIconRss
MainDestination.My -> ThemeConfig.navIconMy
}
if (customIconPath.isNotEmpty()) {
val context = LocalContext.current