From c748a5515ae9d62bf9a26003985faca7fabe159a Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Tue, 12 May 2026 19:12:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E7=9A=84=E7=8B=AC=E5=B1=9E=E4=BA=8EMD?= =?UTF-8?q?3=E7=9A=84=E9=9B=B7=E9=9C=86=E4=B8=BB=E9=A2=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=88=86=E4=BA=AB=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/help/config/ThemeAppearanceConfig.kt | 109 ----- .../app/help/config/ThemeAppearanceJson.kt | 159 -------- .../app/help/config/ThemeImportExport.kt | 11 + .../legado/app/help/config/ThemePackConfig.kt | 327 --------------- .../app/help/config/ThemePackSerializer.kt | 83 ---- .../app/ui/config/themeConfig/ThemeConfig.kt | 16 + .../config/themeConfig/ThemeConfigScreen.kt | 23 +- .../ui/config/themeManage/EditThemeSheet.kt | 369 +++++++++++++++++ .../config/themeManage/ThemeManageScreen.kt | 27 ++ .../ui/config/themePack/ThemePackScreen.kt | 372 ------------------ .../java/io/legado/app/ui/main/MainScreen.kt | 51 +-- 11 files changed, 466 insertions(+), 1081 deletions(-) delete mode 100644 app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt delete mode 100644 app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt delete mode 100644 app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt delete mode 100644 app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt delete mode 100644 app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt diff --git a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt deleted file mode 100644 index 71f6b4d44..000000000 --- a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt +++ /dev/null @@ -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 - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt deleted file mode 100644 index adbafcbd8..000000000 --- a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt +++ /dev/null @@ -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::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) - } - } -} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 625a70ef2..42ff2d072 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -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)) diff --git a/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt b/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt deleted file mode 100644 index d712ef2cf..000000000 --- a/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt +++ /dev/null @@ -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() - val packList: List 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() - 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 - } -} - diff --git a/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt b/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt deleted file mode 100644 index cc7530d40..000000000 --- a/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt +++ /dev/null @@ -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, - JsonDeserializer { - - 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", "") ?: "" - ) - } -} diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index f22e567bb..a7b9f240f 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -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() diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index f3b03a54a..d207d2822 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -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 + ) + } + } } } diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt new file mode 100644 index 000000000..0e54bf998 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt @@ -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 + ) + ) + } + } + ) +} diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt index d599ce367..c7abf1f17 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt @@ -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(null) } var applyTarget by remember { mutableStateOf(null) } var exportTarget by remember { mutableStateOf(null) } + var editTarget by remember { mutableStateOf(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, diff --git a/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt b/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt deleted file mode 100644 index 7664515b2..000000000 --- a/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt +++ /dev/null @@ -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(null) } - var applyTarget by remember { mutableStateOf(null) } - var exportingPack by remember { mutableStateOf(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() - 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 - ) - } - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 552ca5d13..9d363e9ab 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -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