fix: 支持封面图集与 ZIP 主题包
This commit is contained in:
@@ -27,6 +27,10 @@ object LocalPreferencesKeys {
|
|||||||
val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
|
val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
|
||||||
val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
|
val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
|
||||||
val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
|
val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
|
||||||
|
val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
|
||||||
|
val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")
|
||||||
|
val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id")
|
||||||
|
val SELECTED_DARK_COVER_ALBUM_ID = stringPreferencesKey("selected_dark_cover_album_id")
|
||||||
|
|
||||||
// Change source options
|
// Change source options
|
||||||
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
|
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.constant.AppLog
|
||||||
|
import io.legado.app.data.local.preferences.LocalPreferencesKeys
|
||||||
|
import io.legado.app.data.local.preferences.LocalPreferencesRepository
|
||||||
|
import io.legado.app.domain.gateway.CoverAlbumGateway
|
||||||
|
import io.legado.app.domain.model.CoverAlbum
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImage
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImageInput
|
||||||
|
import io.legado.app.domain.model.CoverAlbumSelection
|
||||||
|
import io.legado.app.ui.config.coverConfig.CoverConfig
|
||||||
|
import io.legado.app.utils.GSON
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.security.DigestInputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class CoverAlbumRepository(
|
||||||
|
private val context: Context,
|
||||||
|
private val preferences: LocalPreferencesRepository,
|
||||||
|
) : CoverAlbumGateway {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FORMAT_VERSION = 2
|
||||||
|
private const val INDEX_FILE_NAME = "albums.json"
|
||||||
|
private const val LEGACY_DEFAULT_ALBUM_ID = "default-cover"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val operationMutex = Mutex()
|
||||||
|
private val rootDir = File(
|
||||||
|
context.getExternalFilesDir(null) ?: context.filesDir,
|
||||||
|
"cover_albums",
|
||||||
|
)
|
||||||
|
private val indexFile = File(rootDir, INDEX_FILE_NAME)
|
||||||
|
|
||||||
|
private val initialSelection = runBlocking {
|
||||||
|
InitialSelection(
|
||||||
|
albumId = preferences.getPreference(
|
||||||
|
LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
|
||||||
|
"",
|
||||||
|
).first().ifBlank { null },
|
||||||
|
lightAlbumId = preferences.getPreference(
|
||||||
|
LocalPreferencesKeys.SELECTED_LIGHT_COVER_ALBUM_ID,
|
||||||
|
"",
|
||||||
|
).first().ifBlank { null },
|
||||||
|
darkAlbumId = preferences.getPreference(
|
||||||
|
LocalPreferencesKeys.SELECTED_DARK_COVER_ALBUM_ID,
|
||||||
|
"",
|
||||||
|
).first().ifBlank { null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _albums = MutableStateFlow(loadAlbums(initialSelection))
|
||||||
|
override val albums: StateFlow<List<CoverAlbum>> = _albums.asStateFlow()
|
||||||
|
|
||||||
|
private val _selection = MutableStateFlow(
|
||||||
|
CoverAlbumSelection(
|
||||||
|
albumId = initialSelection.albumId
|
||||||
|
?: initialSelection.lightAlbumId
|
||||||
|
?: initialSelection.darkAlbumId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override val selection: StateFlow<CoverAlbumSelection> = _selection.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
migrateLegacyCoversIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun selectedImagePaths(isDark: Boolean): List<String> {
|
||||||
|
return _albums.value
|
||||||
|
.firstOrNull { it.id == _selection.value.albumId }
|
||||||
|
?.let { if (isDark) it.darkImages else it.lightImages }
|
||||||
|
?.map { it.path }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createAlbum(name: String): String = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val album = CoverAlbum(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim(),
|
||||||
|
lightImages = emptyList(),
|
||||||
|
darkImages = emptyList(),
|
||||||
|
)
|
||||||
|
replaceAlbums(_albums.value + album)
|
||||||
|
album.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importAlbum(
|
||||||
|
name: String,
|
||||||
|
lightImages: List<CoverAlbumImageInput>,
|
||||||
|
darkImages: List<CoverAlbumImageInput>,
|
||||||
|
): String = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val albumId = UUID.randomUUID().toString()
|
||||||
|
val importedLightImages = copyImages(albumId, lightImages)
|
||||||
|
val importedDarkImages = copyImages(albumId, darkImages)
|
||||||
|
val album = CoverAlbum(
|
||||||
|
id = albumId,
|
||||||
|
name = name.trim(),
|
||||||
|
lightImages = importedLightImages,
|
||||||
|
darkImages = importedDarkImages,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
replaceAlbums(_albums.value + album)
|
||||||
|
}.onFailure {
|
||||||
|
File(rootDir, albumId).deleteRecursively()
|
||||||
|
}.getOrThrow()
|
||||||
|
albumId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameAlbum(
|
||||||
|
albumId: String,
|
||||||
|
name: String,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
replaceAlbums(
|
||||||
|
_albums.value.map { album ->
|
||||||
|
if (album.id == albumId) album.copy(name = name.trim()) else album
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAlbum(albumId: String) = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val updatedAlbums = _albums.value.filterNot { it.id == albumId }
|
||||||
|
replaceAlbums(updatedAlbums)
|
||||||
|
if (_selection.value.albumId == albumId) {
|
||||||
|
persistSelection(albumId = null)
|
||||||
|
}
|
||||||
|
File(rootDir, albumId).deleteRecursively()
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addImages(
|
||||||
|
albumId: String,
|
||||||
|
isDark: Boolean,
|
||||||
|
images: List<CoverAlbumImageInput>,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
if (images.isEmpty()) return@withContext
|
||||||
|
operationMutex.withLock {
|
||||||
|
val album = _albums.value.firstOrNull { it.id == albumId } ?: return@withLock
|
||||||
|
val imported = copyImages(albumId, images)
|
||||||
|
replaceAlbums(
|
||||||
|
_albums.value.map {
|
||||||
|
if (it.id != albumId) {
|
||||||
|
it
|
||||||
|
} else if (isDark) {
|
||||||
|
it.copy(
|
||||||
|
darkImages = (it.darkImages + imported).distinctBy { image -> image.id }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.copy(
|
||||||
|
lightImages = (it.lightImages + imported)
|
||||||
|
.distinctBy { image -> image.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeImage(
|
||||||
|
albumId: String,
|
||||||
|
isDark: Boolean,
|
||||||
|
imageId: String,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val album = _albums.value.firstOrNull { it.id == albumId } ?: return@withLock
|
||||||
|
val images = if (isDark) album.darkImages else album.lightImages
|
||||||
|
val image = images.firstOrNull { it.id == imageId } ?: return@withLock
|
||||||
|
replaceAlbums(
|
||||||
|
_albums.value.map {
|
||||||
|
if (it.id == albumId) {
|
||||||
|
if (isDark) {
|
||||||
|
it.copy(
|
||||||
|
darkImages = it.darkImages.filterNot { item -> item.id == imageId }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.copy(
|
||||||
|
lightImages = it.lightImages.filterNot { item -> item.id == imageId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val updatedAlbum = _albums.value.first { it.id == albumId }
|
||||||
|
val stillUsed = (updatedAlbum.lightImages + updatedAlbum.darkImages)
|
||||||
|
.any { it.path == image.path }
|
||||||
|
if (!stillUsed) File(image.path).delete()
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun selectAlbum(albumId: String?) = withContext(Dispatchers.IO) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
if (albumId != null && _albums.value.none { it.id == albumId }) return@withLock
|
||||||
|
persistSelection(albumId)
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateLegacyCoversIfNeeded() {
|
||||||
|
operationMutex.withLock {
|
||||||
|
if (_selection.value.albumId != null &&
|
||||||
|
_albums.value.none { it.id == _selection.value.albumId }
|
||||||
|
) {
|
||||||
|
persistSelection(albumId = null)
|
||||||
|
}
|
||||||
|
val migrated = preferences.getPreference(
|
||||||
|
LocalPreferencesKeys.COVER_ALBUM_MIGRATED,
|
||||||
|
false,
|
||||||
|
).first()
|
||||||
|
val legacyLight = CoverConfig.defaultCover.toExistingFiles()
|
||||||
|
val legacyDark = CoverConfig.defaultCoverDark.toExistingFiles()
|
||||||
|
val needsRecovery = _albums.value.isEmpty() &&
|
||||||
|
_selection.value.albumId == null &&
|
||||||
|
(legacyLight.isNotEmpty() || legacyDark.isNotEmpty())
|
||||||
|
if (migrated && !needsRecovery) {
|
||||||
|
preferences.updatePreference(
|
||||||
|
LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
|
||||||
|
_selection.value.albumId.orEmpty(),
|
||||||
|
)
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedAlbums = _albums.value
|
||||||
|
if (legacyLight.isNotEmpty() || legacyDark.isNotEmpty()) {
|
||||||
|
val existing = updatedAlbums.firstOrNull { it.id == LEGACY_DEFAULT_ALBUM_ID }
|
||||||
|
val album = CoverAlbum(
|
||||||
|
id = LEGACY_DEFAULT_ALBUM_ID,
|
||||||
|
name = existing?.name ?: context.getString(R.string.default_cover),
|
||||||
|
lightImages = (
|
||||||
|
existing?.lightImages.orEmpty() +
|
||||||
|
copyImages(LEGACY_DEFAULT_ALBUM_ID, legacyLight.toInputs())
|
||||||
|
).distinctBy { it.id },
|
||||||
|
darkImages = (
|
||||||
|
existing?.darkImages.orEmpty() +
|
||||||
|
copyImages(LEGACY_DEFAULT_ALBUM_ID, legacyDark.toInputs())
|
||||||
|
).distinctBy { it.id },
|
||||||
|
)
|
||||||
|
updatedAlbums = updatedAlbums.filterNot { it.id == album.id } + album
|
||||||
|
persistSelection(album.id)
|
||||||
|
}
|
||||||
|
if (updatedAlbums != _albums.value) {
|
||||||
|
replaceAlbums(updatedAlbums)
|
||||||
|
}
|
||||||
|
preferences.updatePreference(LocalPreferencesKeys.COVER_ALBUM_MIGRATED, true)
|
||||||
|
syncLegacyCoverPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistSelection(albumId: String?) {
|
||||||
|
_selection.value = CoverAlbumSelection(albumId = albumId)
|
||||||
|
preferences.updatePreference(
|
||||||
|
LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
|
||||||
|
albumId.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncLegacyCoverPaths() {
|
||||||
|
CoverConfig.defaultCover = selectedImagePaths(isDark = false).joinToString(",")
|
||||||
|
CoverConfig.defaultCoverDark = selectedImagePaths(isDark = true).joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyImages(
|
||||||
|
albumId: String,
|
||||||
|
inputs: List<CoverAlbumImageInput>,
|
||||||
|
): List<CoverAlbumImage> {
|
||||||
|
if (inputs.isEmpty()) return emptyList()
|
||||||
|
val albumDir = File(rootDir, albumId).apply { mkdirs() }
|
||||||
|
return inputs.mapNotNull { input ->
|
||||||
|
runCatching {
|
||||||
|
val extension = input.displayName
|
||||||
|
.substringAfterLast('.', "img")
|
||||||
|
.lowercase()
|
||||||
|
.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
|
||||||
|
?: "img"
|
||||||
|
val tempFile = File(albumDir, ".${UUID.randomUUID()}.tmp")
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
input.openStream().use { source ->
|
||||||
|
DigestInputStream(source, digest).use { digestInput ->
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
digestInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val id = digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
val target = File(albumDir, "$id.$extension")
|
||||||
|
if (target.exists()) {
|
||||||
|
tempFile.delete()
|
||||||
|
} else if (!tempFile.renameTo(target)) {
|
||||||
|
tempFile.copyTo(target, overwrite = false)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
CoverAlbumImage(
|
||||||
|
id = target.name,
|
||||||
|
fileName = target.name,
|
||||||
|
path = target.absolutePath,
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
AppLog.put("导入封面图集图片失败\n${it.localizedMessage}", it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceAlbums(albums: List<CoverAlbum>) {
|
||||||
|
persistAlbums(albums)
|
||||||
|
_albums.value = albums
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistAlbums(albums: List<CoverAlbum>) {
|
||||||
|
rootDir.mkdirs()
|
||||||
|
val stored = StoredCoverAlbumIndex(
|
||||||
|
formatVersion = FORMAT_VERSION,
|
||||||
|
albums = albums.map { album ->
|
||||||
|
StoredCoverAlbum(
|
||||||
|
id = album.id,
|
||||||
|
name = album.name,
|
||||||
|
lightImages = album.lightImages.map { it.fileName },
|
||||||
|
darkImages = album.darkImages.map { it.fileName },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val temp = File(rootDir, "$INDEX_FILE_NAME.tmp")
|
||||||
|
FileOutputStream(temp).bufferedWriter().use { writer ->
|
||||||
|
GSON.toJson(stored, writer)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
java.nio.file.Files.move(
|
||||||
|
temp.toPath(),
|
||||||
|
indexFile.toPath(),
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
java.nio.file.Files.move(
|
||||||
|
temp.toPath(),
|
||||||
|
indexFile.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAlbums(selection: InitialSelection): List<CoverAlbum> {
|
||||||
|
if (!indexFile.isFile) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
val stored = indexFile.bufferedReader().use {
|
||||||
|
GSON.fromJson(it, StoredCoverAlbumIndex::class.java)
|
||||||
|
}
|
||||||
|
var albums = stored.albums.map { album ->
|
||||||
|
val legacyImages = album.images.mapExistingImages(album.id)
|
||||||
|
val lightFileNames = if (stored.formatVersion >= FORMAT_VERSION) {
|
||||||
|
album.lightImages
|
||||||
|
} else if (album.id == selection.darkAlbumId &&
|
||||||
|
album.id != selection.lightAlbumId
|
||||||
|
) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
album.images
|
||||||
|
}
|
||||||
|
val darkFileNames = if (stored.formatVersion >= FORMAT_VERSION) {
|
||||||
|
album.darkImages
|
||||||
|
} else if (album.id == selection.darkAlbumId) {
|
||||||
|
album.images
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
CoverAlbum(
|
||||||
|
id = album.id,
|
||||||
|
name = album.name,
|
||||||
|
lightImages = if (stored.formatVersion >= FORMAT_VERSION) {
|
||||||
|
lightFileNames.mapExistingImages(album.id)
|
||||||
|
} else if (lightFileNames == album.images) {
|
||||||
|
legacyImages
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
darkImages = if (stored.formatVersion >= FORMAT_VERSION) {
|
||||||
|
darkFileNames.mapExistingImages(album.id)
|
||||||
|
} else if (darkFileNames == album.images) {
|
||||||
|
legacyImages
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stored.formatVersion < FORMAT_VERSION &&
|
||||||
|
selection.lightAlbumId != null &&
|
||||||
|
selection.darkAlbumId != null &&
|
||||||
|
selection.lightAlbumId != selection.darkAlbumId
|
||||||
|
) {
|
||||||
|
val lightAlbum = albums.firstOrNull { it.id == selection.lightAlbumId }
|
||||||
|
val darkAlbum = albums.firstOrNull { it.id == selection.darkAlbumId }
|
||||||
|
if (lightAlbum != null && darkAlbum != null) {
|
||||||
|
val copiedDarkImages = copyImages(
|
||||||
|
albumId = lightAlbum.id,
|
||||||
|
inputs = darkAlbum.darkImages.map { image ->
|
||||||
|
CoverAlbumImageInput(image.fileName) { File(image.path).inputStream() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
albums = albums.map { album ->
|
||||||
|
if (album.id == lightAlbum.id) {
|
||||||
|
album.copy(darkImages = copiedDarkImages)
|
||||||
|
} else {
|
||||||
|
album
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stored.formatVersion < FORMAT_VERSION) {
|
||||||
|
persistAlbums(albums)
|
||||||
|
}
|
||||||
|
albums
|
||||||
|
}.onFailure {
|
||||||
|
AppLog.put("读取封面图集失败\n${it.localizedMessage}", it)
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.mapExistingImages(albumId: String): List<CoverAlbumImage> =
|
||||||
|
mapNotNull { fileName ->
|
||||||
|
val file = File(File(rootDir, albumId), fileName)
|
||||||
|
if (!file.isFile) return@mapNotNull null
|
||||||
|
CoverAlbumImage(
|
||||||
|
id = file.name,
|
||||||
|
fileName = file.name,
|
||||||
|
path = file.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toExistingFiles(): List<File> =
|
||||||
|
split(",")
|
||||||
|
.asSequence()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.map(::File)
|
||||||
|
.filter(File::isFile)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
private fun List<File>.toInputs(): List<CoverAlbumImageInput> = map { file ->
|
||||||
|
CoverAlbumImageInput(file.name) { file.inputStream() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class InitialSelection(
|
||||||
|
val albumId: String?,
|
||||||
|
val lightAlbumId: String?,
|
||||||
|
val darkAlbumId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
private data class StoredCoverAlbumIndex(
|
||||||
|
@SerializedName("formatVersion")
|
||||||
|
val formatVersion: Int = FORMAT_VERSION,
|
||||||
|
@SerializedName("albums")
|
||||||
|
val albums: List<StoredCoverAlbum> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
private data class StoredCoverAlbum(
|
||||||
|
@SerializedName("id")
|
||||||
|
val id: String = "",
|
||||||
|
@SerializedName("name")
|
||||||
|
val name: String = "",
|
||||||
|
@SerializedName("lightImages")
|
||||||
|
val lightImages: List<String> = emptyList(),
|
||||||
|
@SerializedName("darkImages")
|
||||||
|
val darkImages: List<String> = emptyList(),
|
||||||
|
@SerializedName("images")
|
||||||
|
val images: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import io.legado.app.data.repository.BookSourceCallbackRepository
|
|||||||
import io.legado.app.data.repository.BookSourceRepository
|
import io.legado.app.data.repository.BookSourceRepository
|
||||||
import io.legado.app.data.repository.BookshelfRepository
|
import io.legado.app.data.repository.BookshelfRepository
|
||||||
import io.legado.app.data.repository.CacheBookDownloadRepository
|
import io.legado.app.data.repository.CacheBookDownloadRepository
|
||||||
|
import io.legado.app.data.repository.CoverAlbumRepository
|
||||||
import io.legado.app.data.repository.DatabaseMaintenanceRepository
|
import io.legado.app.data.repository.DatabaseMaintenanceRepository
|
||||||
import io.legado.app.data.repository.DictRuleRepository
|
import io.legado.app.data.repository.DictRuleRepository
|
||||||
import io.legado.app.data.repository.DictionaryRepositoryImpl
|
import io.legado.app.data.repository.DictionaryRepositoryImpl
|
||||||
@@ -60,6 +61,7 @@ import io.legado.app.domain.gateway.BookCacheCleanupGateway
|
|||||||
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
||||||
import io.legado.app.domain.gateway.BookSearchGateway
|
import io.legado.app.domain.gateway.BookSearchGateway
|
||||||
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
||||||
|
import io.legado.app.domain.gateway.CoverAlbumGateway
|
||||||
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
|
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
|
||||||
import io.legado.app.domain.gateway.DictionaryGateway
|
import io.legado.app.domain.gateway.DictionaryGateway
|
||||||
import io.legado.app.domain.gateway.ExploreBooksGateway
|
import io.legado.app.domain.gateway.ExploreBooksGateway
|
||||||
@@ -80,6 +82,7 @@ import io.legado.app.domain.usecase.CacheBookChaptersUseCase
|
|||||||
import io.legado.app.domain.usecase.ChangeBookSourceUseCase
|
import io.legado.app.domain.usecase.ChangeBookSourceUseCase
|
||||||
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
|
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
|
||||||
import io.legado.app.domain.usecase.ClearBookCacheUseCase
|
import io.legado.app.domain.usecase.ClearBookCacheUseCase
|
||||||
|
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||||
import io.legado.app.domain.usecase.DeleteBooksUseCase
|
import io.legado.app.domain.usecase.DeleteBooksUseCase
|
||||||
import io.legado.app.domain.usecase.ExploreBooksUseCase
|
import io.legado.app.domain.usecase.ExploreBooksUseCase
|
||||||
import io.legado.app.domain.usecase.ExploreKindUiUseCase
|
import io.legado.app.domain.usecase.ExploreKindUiUseCase
|
||||||
@@ -102,6 +105,7 @@ import io.legado.app.domain.usecase.WebDavBackupUseCase
|
|||||||
import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
|
import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
|
||||||
import io.legado.app.help.coil.CoverFetcher
|
import io.legado.app.help.coil.CoverFetcher
|
||||||
import io.legado.app.help.coil.CoverInterceptor
|
import io.legado.app.help.coil.CoverInterceptor
|
||||||
|
import io.legado.app.help.config.ThemePackageManager
|
||||||
import io.legado.app.help.http.okHttpClient
|
import io.legado.app.help.http.okHttpClient
|
||||||
import io.legado.app.help.http.okHttpClientManga
|
import io.legado.app.help.http.okHttpClientManga
|
||||||
import io.legado.app.ui.about.AboutViewModel
|
import io.legado.app.ui.about.AboutViewModel
|
||||||
@@ -132,12 +136,14 @@ import io.legado.app.ui.config.ai.AiConfigViewModel
|
|||||||
import io.legado.app.ui.config.ai.AiModelEditViewModel
|
import io.legado.app.ui.config.ai.AiModelEditViewModel
|
||||||
import io.legado.app.ui.config.ai.AiProviderEditViewModel
|
import io.legado.app.ui.config.ai.AiProviderEditViewModel
|
||||||
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
|
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
|
||||||
|
import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
|
||||||
import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
|
import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
|
||||||
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
|
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
|
||||||
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
|
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
|
||||||
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
|
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
|
||||||
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
|
import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
|
||||||
|
import io.legado.app.ui.config.themeManage.ThemeManageViewModel
|
||||||
import io.legado.app.ui.dict.DictViewModel
|
import io.legado.app.ui.dict.DictViewModel
|
||||||
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
||||||
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
|
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
|
||||||
@@ -198,6 +204,7 @@ val appModule = module {
|
|||||||
singleOf(::CacheBookChaptersUseCase)
|
singleOf(::CacheBookChaptersUseCase)
|
||||||
singleOf(::ChangeBookSourceUseCase)
|
singleOf(::ChangeBookSourceUseCase)
|
||||||
singleOf(::ClearBookCacheUseCase)
|
singleOf(::ClearBookCacheUseCase)
|
||||||
|
singleOf(::CoverAlbumUseCase)
|
||||||
singleOf(::DeleteBooksUseCase)
|
singleOf(::DeleteBooksUseCase)
|
||||||
singleOf(::GetReadingProgressUseCase)
|
singleOf(::GetReadingProgressUseCase)
|
||||||
single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
|
single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
|
||||||
@@ -214,6 +221,7 @@ val appModule = module {
|
|||||||
singleOf(::ShrinkDatabaseUseCase)
|
singleOf(::ShrinkDatabaseUseCase)
|
||||||
singleOf(::WebDavBackupUseCase)
|
singleOf(::WebDavBackupUseCase)
|
||||||
singleOf(::BookshelfManageScreenConfig)
|
singleOf(::BookshelfManageScreenConfig)
|
||||||
|
singleOf(::ThemePackageManager)
|
||||||
|
|
||||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||||
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
|
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
|
||||||
@@ -227,6 +235,7 @@ val appModule = module {
|
|||||||
single<BackupRestoreGateway> { BackupRestoreRepository() }
|
single<BackupRestoreGateway> { BackupRestoreRepository() }
|
||||||
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
||||||
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
||||||
|
single<CoverAlbumGateway> { CoverAlbumRepository(get(), get()) }
|
||||||
single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
|
single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
|
||||||
single<LocalBookGateway> { LocalBookRepository(get()) }
|
single<LocalBookGateway> { LocalBookRepository(get()) }
|
||||||
single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
|
single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
|
||||||
@@ -293,8 +302,10 @@ val appModule = module {
|
|||||||
viewModelOf(::OtherConfigViewModel)
|
viewModelOf(::OtherConfigViewModel)
|
||||||
viewModelOf(::ReadConfigViewModel)
|
viewModelOf(::ReadConfigViewModel)
|
||||||
viewModelOf(::CoverConfigViewModel)
|
viewModelOf(::CoverConfigViewModel)
|
||||||
|
viewModelOf(::CoverAlbumManageViewModel)
|
||||||
viewModelOf(::DownloadCacheConfigViewModel)
|
viewModelOf(::DownloadCacheConfigViewModel)
|
||||||
viewModelOf(::ThemeConfigViewModel)
|
viewModelOf(::ThemeConfigViewModel)
|
||||||
|
viewModelOf(::ThemeManageViewModel)
|
||||||
viewModelOf(::BackupConfigViewModel)
|
viewModelOf(::BackupConfigViewModel)
|
||||||
viewModelOf(::AiConfigViewModel)
|
viewModelOf(::AiConfigViewModel)
|
||||||
viewModelOf(::AiChatViewModel)
|
viewModelOf(::AiChatViewModel)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package io.legado.app.domain.gateway
|
||||||
|
|
||||||
|
import io.legado.app.domain.model.CoverAlbum
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImageInput
|
||||||
|
import io.legado.app.domain.model.CoverAlbumSelection
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface CoverAlbumGateway {
|
||||||
|
|
||||||
|
val albums: StateFlow<List<CoverAlbum>>
|
||||||
|
val selection: StateFlow<CoverAlbumSelection>
|
||||||
|
|
||||||
|
fun selectedImagePaths(isDark: Boolean): List<String>
|
||||||
|
|
||||||
|
suspend fun createAlbum(name: String): String
|
||||||
|
suspend fun importAlbum(
|
||||||
|
name: String,
|
||||||
|
lightImages: List<CoverAlbumImageInput>,
|
||||||
|
darkImages: List<CoverAlbumImageInput>,
|
||||||
|
): String
|
||||||
|
suspend fun renameAlbum(albumId: String, name: String)
|
||||||
|
suspend fun deleteAlbum(albumId: String)
|
||||||
|
suspend fun addImages(
|
||||||
|
albumId: String,
|
||||||
|
isDark: Boolean,
|
||||||
|
images: List<CoverAlbumImageInput>,
|
||||||
|
)
|
||||||
|
suspend fun removeImage(albumId: String, isDark: Boolean, imageId: String)
|
||||||
|
suspend fun selectAlbum(albumId: String?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package io.legado.app.domain.model
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
data class CoverAlbum(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val lightImages: List<CoverAlbumImage>,
|
||||||
|
val darkImages: List<CoverAlbumImage>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CoverAlbumImage(
|
||||||
|
val id: String,
|
||||||
|
val fileName: String,
|
||||||
|
val path: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CoverAlbumSelection(
|
||||||
|
val albumId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CoverAlbumImageInput(
|
||||||
|
val displayName: String,
|
||||||
|
val openStream: () -> InputStream,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package io.legado.app.domain.usecase
|
||||||
|
|
||||||
|
import io.legado.app.domain.gateway.CoverAlbumGateway
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImageInput
|
||||||
|
|
||||||
|
class CoverAlbumUseCase(
|
||||||
|
private val gateway: CoverAlbumGateway,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val albums = gateway.albums
|
||||||
|
val selection = gateway.selection
|
||||||
|
|
||||||
|
fun selectedImagePaths(isDark: Boolean) = gateway.selectedImagePaths(isDark)
|
||||||
|
|
||||||
|
suspend fun createAlbum(name: String) = gateway.createAlbum(name)
|
||||||
|
|
||||||
|
suspend fun importAlbum(
|
||||||
|
name: String,
|
||||||
|
lightImages: List<CoverAlbumImageInput>,
|
||||||
|
darkImages: List<CoverAlbumImageInput>,
|
||||||
|
) = gateway.importAlbum(name, lightImages, darkImages)
|
||||||
|
|
||||||
|
suspend fun renameAlbum(albumId: String, name: String) =
|
||||||
|
gateway.renameAlbum(albumId, name)
|
||||||
|
|
||||||
|
suspend fun deleteAlbum(albumId: String) = gateway.deleteAlbum(albumId)
|
||||||
|
|
||||||
|
suspend fun addImages(
|
||||||
|
albumId: String,
|
||||||
|
isDark: Boolean,
|
||||||
|
images: List<CoverAlbumImageInput>,
|
||||||
|
) = gateway.addImages(albumId, isDark, images)
|
||||||
|
|
||||||
|
suspend fun removeImage(albumId: String, isDark: Boolean, imageId: String) =
|
||||||
|
gateway.removeImage(albumId, isDark, imageId)
|
||||||
|
|
||||||
|
suspend fun selectAlbum(albumId: String?) = gateway.selectAlbum(albumId)
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import android.content.Context
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.Keep
|
import androidx.annotation.Keep
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
import io.legado.app.ui.config.coverConfig.CoverConfig
|
import io.legado.app.ui.config.coverConfig.CoverConfig
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
||||||
import io.legado.app.utils.EncoderUtils
|
import io.legado.app.utils.EncoderUtils
|
||||||
@@ -41,7 +44,8 @@ object ThemeImportExport {
|
|||||||
if (file.isFile && file.extension == "json") {
|
if (file.isFile && file.extension == "json") {
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
val json = file.readText()
|
val json = file.readText()
|
||||||
val data = EXPORT_GSON.fromJson(json, ThemeExportData::class.java)
|
val data = parseThemeData(json)
|
||||||
|
?: error("不支持的主题配置格式")
|
||||||
val name = file.nameWithoutExtension
|
val name = file.nameWithoutExtension
|
||||||
_savedThemes.add(SavedTheme(name = name, data = data))
|
_savedThemes.add(SavedTheme(name = name, data = data))
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ object ThemeImportExport {
|
|||||||
/**
|
/**
|
||||||
* 从当前配置创建导出数据
|
* 从当前配置创建导出数据
|
||||||
*/
|
*/
|
||||||
fun exportFromCurrent(): ThemeExportData {
|
fun exportFromCurrent(includeEmbeddedAssets: Boolean = true): ThemeExportData {
|
||||||
return ThemeExportData(
|
return ThemeExportData(
|
||||||
// 基础主题设置
|
// 基础主题设置
|
||||||
appTheme = ThemeConfig.appTheme,
|
appTheme = ThemeConfig.appTheme,
|
||||||
@@ -229,7 +233,7 @@ object ThemeImportExport {
|
|||||||
coverShowNameN = CoverConfig.coverShowNameN,
|
coverShowNameN = CoverConfig.coverShowNameN,
|
||||||
coverShowAuthorN = CoverConfig.coverShowAuthorN,
|
coverShowAuthorN = CoverConfig.coverShowAuthorN,
|
||||||
coverInfoOrientation = CoverConfig.coverInfoOrientation,
|
coverInfoOrientation = CoverConfig.coverInfoOrientation,
|
||||||
assets = exportAssets()
|
assets = if (includeEmbeddedAssets) exportAssets() else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +421,7 @@ object ThemeImportExport {
|
|||||||
val coverPaths = mutableMapOf<String, MutableList<String>>()
|
val coverPaths = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
assets.forEach { (key, base64) ->
|
assets.forEach { (key, base64) ->
|
||||||
|
if (base64.isBlank()) return@forEach
|
||||||
try {
|
try {
|
||||||
val bytes = EncoderUtils.base64DecodeToByteArray(base64)
|
val bytes = EncoderUtils.base64DecodeToByteArray(base64)
|
||||||
val destFile = when {
|
val destFile = when {
|
||||||
@@ -499,7 +504,7 @@ object ThemeImportExport {
|
|||||||
*/
|
*/
|
||||||
fun importFromJson(json: String): Boolean {
|
fun importFromJson(json: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val data = GSON.fromJson(json, ThemeExportData::class.java)
|
val data = parseThemeData(json) ?: return false
|
||||||
applyToThemeConfig(data)
|
applyToThemeConfig(data)
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -508,6 +513,123 @@ object ThemeImportExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseThemeData(json: String): ThemeExportData? {
|
||||||
|
val root = JsonParser.parseString(json).asJsonObject
|
||||||
|
return when {
|
||||||
|
root.has("appTheme") && root.has("themeMode") ->
|
||||||
|
GSON.fromJson(root, ThemeExportData::class.java)
|
||||||
|
|
||||||
|
root.has("a") && root.has("A") && root.has("y0") ->
|
||||||
|
parseObfuscatedThemeV1(root)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容曾由 R8 混淆字段名导出的主题配置。
|
||||||
|
* 该映射对应加入首页开关、但尚未加入导航顺序和日夜独立配色的旧格式。
|
||||||
|
*/
|
||||||
|
private fun parseObfuscatedThemeV1(root: JsonObject): ThemeExportData {
|
||||||
|
val assetsType = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
return ThemeExportData(
|
||||||
|
appTheme = root.string("a", "0"),
|
||||||
|
themeMode = root.string("b", "0"),
|
||||||
|
isPureBlack = root.boolean("c"),
|
||||||
|
composeEngine = root.string("d", "material"),
|
||||||
|
paletteStyle = root.string("e", "tonalSpot"),
|
||||||
|
materialVersion = root.string("f", "material3"),
|
||||||
|
customMode = root.nullableString("g"),
|
||||||
|
customContrast = root.string("h", "Default"),
|
||||||
|
launcherIcon = root.string("i", "ic_launcher"),
|
||||||
|
isPredictiveBackEnabled = root.boolean("j", true),
|
||||||
|
fontScale = root.int("k", 10),
|
||||||
|
enableDeepPersonalization = root.boolean("l"),
|
||||||
|
cPrimary = root.int("m"),
|
||||||
|
cNPrimary = root.int("n"),
|
||||||
|
themeColor = root.int("o"),
|
||||||
|
secondaryThemeColor = root.int("p"),
|
||||||
|
primaryTextColor = root.int("q"),
|
||||||
|
secondaryTextColor = root.int("r"),
|
||||||
|
themeBackgroundColor = root.int("s"),
|
||||||
|
labelContainerColor = root.int("t"),
|
||||||
|
bookInfoInputColor = root.int("u"),
|
||||||
|
containerOpacity = root.int("v", 100),
|
||||||
|
enableItemDivider = root.boolean("w"),
|
||||||
|
itemDividerWidth = root.float("x", 1f),
|
||||||
|
itemDividerLength = root.float("y", 80f),
|
||||||
|
itemDividerColor = root.int("z"),
|
||||||
|
enableBlur = root.boolean("A"),
|
||||||
|
enableProgressiveBlur = root.boolean("B"),
|
||||||
|
topBarBlurRadius = root.int("C", 24),
|
||||||
|
bottomBarBlurRadius = root.int("D", 8),
|
||||||
|
topBarBlurAlpha = root.int("E", 73),
|
||||||
|
bottomBarBlurAlpha = root.int("F", 40),
|
||||||
|
bottomBarLensRadius = root.float("G", 24f),
|
||||||
|
topBarOpacity = root.int("H", 100),
|
||||||
|
bottomBarOpacity = root.int("I", 100),
|
||||||
|
enableCustomTagColors = root.boolean("J"),
|
||||||
|
customTagColorsJson = root.nullableString("K"),
|
||||||
|
showHome = root.boolean("L", true),
|
||||||
|
showDiscovery = root.boolean("M", true),
|
||||||
|
showRss = root.boolean("N", true),
|
||||||
|
showStatusBar = root.boolean("O", true),
|
||||||
|
swipeAnimation = root.boolean("P", true),
|
||||||
|
showBottomView = root.boolean("Q", true),
|
||||||
|
useFloatingBottomBar = root.boolean("R"),
|
||||||
|
useFloatingBottomBarLiquidGlass = root.boolean("S"),
|
||||||
|
tabletInterface = root.string("T", "auto"),
|
||||||
|
labelVisibilityMode = root.string("U", "auto"),
|
||||||
|
defaultHomePage = root.string("V", "bookshelf"),
|
||||||
|
navIconHome = root.string("W"),
|
||||||
|
navIconBookshelf = root.string("X"),
|
||||||
|
navIconExplore = root.string("Y"),
|
||||||
|
navIconRss = root.string("Z"),
|
||||||
|
navIconMy = root.string("a0"),
|
||||||
|
useMiuixMonet = root.boolean("b0"),
|
||||||
|
useFlexibleTopAppBar = root.boolean("c0", true),
|
||||||
|
bgImageLight = root.nullableString("d0"),
|
||||||
|
bgImageDark = root.nullableString("e0"),
|
||||||
|
bgImageBlurring = root.int("f0"),
|
||||||
|
bgImageNBlurring = root.int("g0"),
|
||||||
|
appFontPath = root.nullableString("h0"),
|
||||||
|
coverLoadOnlyWifi = root.boolean("i0"),
|
||||||
|
coverUseDefault = root.boolean("j0"),
|
||||||
|
coverShowShadow = root.boolean("k0"),
|
||||||
|
coverShowStroke = root.boolean("l0", true),
|
||||||
|
coverDefaultColor = root.boolean("m0", true),
|
||||||
|
coverDefaultImage = root.string("n0"),
|
||||||
|
coverTextColor = root.int("o0", -16777216),
|
||||||
|
coverShadowColor = root.int("p0", -16777216),
|
||||||
|
coverShowName = root.boolean("q0", true),
|
||||||
|
coverShowAuthor = root.boolean("r0", true),
|
||||||
|
coverDefaultImageDark = root.string("s0"),
|
||||||
|
coverTextColorN = root.int("t0", -1),
|
||||||
|
coverShadowColorN = root.int("u0", -1),
|
||||||
|
coverShowNameN = root.boolean("v0", true),
|
||||||
|
coverShowAuthorN = root.boolean("w0", true),
|
||||||
|
coverInfoOrientation = root.string("x0", "0"),
|
||||||
|
assets = root.get("y0")?.takeUnless { it.isJsonNull }?.let {
|
||||||
|
GSON.fromJson(it, assetsType)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String, default: String = ""): String =
|
||||||
|
get(key)?.takeUnless { it.isJsonNull }?.asString ?: default
|
||||||
|
|
||||||
|
private fun JsonObject.nullableString(key: String): String? =
|
||||||
|
get(key)?.takeUnless { it.isJsonNull }?.asString
|
||||||
|
|
||||||
|
private fun JsonObject.boolean(key: String, default: Boolean = false): Boolean =
|
||||||
|
get(key)?.takeUnless { it.isJsonNull }?.asBoolean ?: default
|
||||||
|
|
||||||
|
private fun JsonObject.int(key: String, default: Int = 0): Int =
|
||||||
|
get(key)?.takeUnless { it.isJsonNull }?.asInt ?: default
|
||||||
|
|
||||||
|
private fun JsonObject.float(key: String, default: Float = 0f): Float =
|
||||||
|
get(key)?.takeUnless { it.isJsonNull }?.asFloat ?: default
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从文件URI导入主题
|
* 从文件URI导入主题
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package io.legado.app.help.config
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImageInput
|
||||||
|
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||||
|
import io.legado.app.ui.config.coverConfig.CoverConfig
|
||||||
|
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
||||||
|
import io.legado.app.utils.GSON
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class ThemePackageManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val coverAlbumUseCase: CoverAlbumUseCase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FILE_EXTENSION = "zip"
|
||||||
|
private const val FORMAT_VERSION = 1
|
||||||
|
private const val MANIFEST_PATH = "manifest.json"
|
||||||
|
private const val MAX_ENTRY_COUNT = 4_096
|
||||||
|
private const val MAX_ENTRY_BYTES = 64L * 1024 * 1024
|
||||||
|
private const val MAX_TOTAL_BYTES = 512L * 1024 * 1024
|
||||||
|
|
||||||
|
private const val ASSET_BACKGROUND_LIGHT = "background.light"
|
||||||
|
private const val ASSET_BACKGROUND_DARK = "background.dark"
|
||||||
|
private const val ASSET_NAV_HOME = "navigation.home"
|
||||||
|
private const val ASSET_NAV_BOOKSHELF = "navigation.bookshelf"
|
||||||
|
private const val ASSET_NAV_EXPLORE = "navigation.explore"
|
||||||
|
private const val ASSET_NAV_RSS = "navigation.rss"
|
||||||
|
private const val ASSET_NAV_MY = "navigation.my"
|
||||||
|
private const val ASSET_FONT = "font.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportPackage(
|
||||||
|
uri: Uri,
|
||||||
|
themeName: String? = null,
|
||||||
|
themeData: ThemeExportData? = null,
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runSuspendCatching {
|
||||||
|
val rawConfig = themeData ?: ThemeImportExport.exportFromCurrent(
|
||||||
|
includeEmbeddedAssets = false
|
||||||
|
)
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
|
ZipOutputStream(BufferedOutputStream(output)).use { zip ->
|
||||||
|
val assetEntries = exportAssets(zip, rawConfig)
|
||||||
|
val coverData = exportCoverAlbums(zip)
|
||||||
|
val manifest = ThemePackageManifest(
|
||||||
|
formatVersion = FORMAT_VERSION,
|
||||||
|
name = themeName,
|
||||||
|
config = rawConfig.toPortableConfig(),
|
||||||
|
assets = assetEntries,
|
||||||
|
coverAlbums = coverData.albums,
|
||||||
|
coverSelection = coverData.selection,
|
||||||
|
)
|
||||||
|
zip.writeEntry(
|
||||||
|
MANIFEST_PATH,
|
||||||
|
GSON.toJson(manifest).byteInputStream(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: error("无法创建主题包")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importPackage(uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runSuspendCatching {
|
||||||
|
val tempRoot = File(context.cacheDir, "theme_import/${UUID.randomUUID()}")
|
||||||
|
val importedAlbumIds = mutableListOf<String>()
|
||||||
|
val copiedAssets = mutableListOf<File>()
|
||||||
|
try {
|
||||||
|
extractPackage(uri, tempRoot)
|
||||||
|
val manifestFile = resolvePackageFile(tempRoot, MANIFEST_PATH)
|
||||||
|
val manifestJson = manifestFile.readText()
|
||||||
|
val manifestRoot = JsonParser.parseString(manifestJson).asJsonObject
|
||||||
|
require(manifestRoot.has("formatVersion") && manifestRoot.has("config")) {
|
||||||
|
"主题包清单不完整"
|
||||||
|
}
|
||||||
|
val manifest = GSON.fromJson(manifestJson, ThemePackageManifest::class.java)
|
||||||
|
require(manifest.formatVersion == FORMAT_VERSION) {
|
||||||
|
"不支持的主题包版本: ${manifest.formatVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val localAssets = importAssets(
|
||||||
|
root = tempRoot,
|
||||||
|
entries = manifest.assets,
|
||||||
|
copiedFiles = copiedAssets,
|
||||||
|
)
|
||||||
|
val albumIdMap = importCoverAlbums(
|
||||||
|
root = tempRoot,
|
||||||
|
albums = manifest.coverAlbums,
|
||||||
|
importedIds = importedAlbumIds,
|
||||||
|
)
|
||||||
|
val selectedAlbumId = resolveAlbumRef(
|
||||||
|
manifest.coverSelection.albumRef,
|
||||||
|
albumIdMap,
|
||||||
|
)
|
||||||
|
ThemeImportExport.applyToThemeConfig(
|
||||||
|
manifest.config.copy(
|
||||||
|
bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
|
||||||
|
bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
|
||||||
|
navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
|
||||||
|
navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
|
||||||
|
navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
|
||||||
|
navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
|
||||||
|
navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
|
||||||
|
appFontPath = localAssets[ASSET_FONT],
|
||||||
|
coverDefaultImage = "",
|
||||||
|
coverDefaultImageDark = "",
|
||||||
|
assets = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coverAlbumUseCase.selectAlbum(selectedAlbumId)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
importedAlbumIds.forEach { id ->
|
||||||
|
runCatching { coverAlbumUseCase.deleteAlbum(id) }
|
||||||
|
}
|
||||||
|
copiedAssets.forEach(File::delete)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
tempRoot.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importLegacyJson(uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runSuspendCatching {
|
||||||
|
val json = context.contentResolver.openInputStream(uri)?.use {
|
||||||
|
it.bufferedReader().readText()
|
||||||
|
} ?: error("无法读取旧主题配置")
|
||||||
|
check(ThemeImportExport.importFromJson(json)) {
|
||||||
|
"旧主题配置格式无效或字段已被混淆"
|
||||||
|
}
|
||||||
|
importLegacyCoverAlbums()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportAssets(
|
||||||
|
zip: ZipOutputStream,
|
||||||
|
config: ThemeExportData,
|
||||||
|
): Map<String, String> {
|
||||||
|
val result = linkedMapOf<String, String>()
|
||||||
|
val sources = listOf(
|
||||||
|
AssetSource(ASSET_BACKGROUND_LIGHT, config.bgImageLight, "assets/background/light"),
|
||||||
|
AssetSource(ASSET_BACKGROUND_DARK, config.bgImageDark, "assets/background/dark"),
|
||||||
|
AssetSource(ASSET_NAV_HOME, config.navIconHome, "assets/navigation/home"),
|
||||||
|
AssetSource(
|
||||||
|
ASSET_NAV_BOOKSHELF,
|
||||||
|
config.navIconBookshelf,
|
||||||
|
"assets/navigation/bookshelf",
|
||||||
|
),
|
||||||
|
AssetSource(ASSET_NAV_EXPLORE, config.navIconExplore, "assets/navigation/explore"),
|
||||||
|
AssetSource(ASSET_NAV_RSS, config.navIconRss, "assets/navigation/rss"),
|
||||||
|
AssetSource(ASSET_NAV_MY, config.navIconMy, "assets/navigation/my"),
|
||||||
|
AssetSource(ASSET_FONT, config.appFontPath, "assets/fonts/app"),
|
||||||
|
)
|
||||||
|
sources.forEach { source ->
|
||||||
|
val path = source.sourcePath?.takeIf(String::isNotBlank) ?: return@forEach
|
||||||
|
val extension = sourceExtension(path, source.key)
|
||||||
|
val entryPath = "${source.entryBase}.$extension"
|
||||||
|
openSource(path).use { input ->
|
||||||
|
zip.writeEntry(entryPath, input)
|
||||||
|
}
|
||||||
|
result[source.key] = entryPath
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportCoverAlbums(zip: ZipOutputStream): ExportedCoverData {
|
||||||
|
val selection = coverAlbumUseCase.selection.value
|
||||||
|
val albumsById = coverAlbumUseCase.albums.value.associateBy { it.id }
|
||||||
|
val selectedIds = listOfNotNull(selection.albumId)
|
||||||
|
val refById = selectedIds.associateWith { "album_0" }
|
||||||
|
val exportedAlbums = selectedIds.mapNotNull { albumId ->
|
||||||
|
val album = albumsById[albumId] ?: return@mapNotNull null
|
||||||
|
val albumRef = refById.getValue(albumId)
|
||||||
|
fun exportImages(
|
||||||
|
group: String,
|
||||||
|
images: List<io.legado.app.domain.model.CoverAlbumImage>,
|
||||||
|
) = images.mapIndexed { index, image ->
|
||||||
|
val file = File(image.path)
|
||||||
|
require(file.isFile) { "图集图片不存在: ${image.path}" }
|
||||||
|
val extension = file.extension
|
||||||
|
.lowercase()
|
||||||
|
.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
|
||||||
|
?: "img"
|
||||||
|
val entryPath = "cover-albums/$albumRef/$group/image_$index.$extension"
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
zip.writeEntry(entryPath, input)
|
||||||
|
}
|
||||||
|
ThemePackageCoverImage(path = entryPath)
|
||||||
|
}
|
||||||
|
ThemePackageCoverAlbum(
|
||||||
|
ref = albumRef,
|
||||||
|
name = album.name,
|
||||||
|
lightImages = exportImages("light", album.lightImages),
|
||||||
|
darkImages = exportImages("dark", album.darkImages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ExportedCoverData(
|
||||||
|
albums = exportedAlbums,
|
||||||
|
selection = ThemePackageCoverSelection(
|
||||||
|
albumRef = selection.albumId?.let(refById::get),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importAssets(
|
||||||
|
root: File,
|
||||||
|
entries: Map<String, String>,
|
||||||
|
copiedFiles: MutableList<File>,
|
||||||
|
): Map<String, String> {
|
||||||
|
return entries.mapNotNull { (key, entryPath) ->
|
||||||
|
val source = resolvePackageFile(root, entryPath)
|
||||||
|
require(source.isFile) { "主题资源不存在: $entryPath" }
|
||||||
|
val extension = source.extension
|
||||||
|
.lowercase()
|
||||||
|
.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
|
||||||
|
?: "asset"
|
||||||
|
val targetDir = when (key) {
|
||||||
|
ASSET_BACKGROUND_LIGHT, ASSET_BACKGROUND_DARK -> {
|
||||||
|
File(context.getExternalFilesDir(null) ?: context.filesDir, "theme_assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSET_NAV_HOME, ASSET_NAV_BOOKSHELF, ASSET_NAV_EXPLORE,
|
||||||
|
ASSET_NAV_RSS, ASSET_NAV_MY -> File(context.filesDir, "nav_icons")
|
||||||
|
|
||||||
|
ASSET_FONT -> File(context.filesDir, "fonts")
|
||||||
|
else -> return@mapNotNull null
|
||||||
|
}.apply { mkdirs() }
|
||||||
|
val target = File(
|
||||||
|
targetDir,
|
||||||
|
"theme_${key.substringAfterLast('.')}_${UUID.randomUUID()}.$extension",
|
||||||
|
)
|
||||||
|
source.copyTo(target)
|
||||||
|
copiedFiles += target
|
||||||
|
key to target.absolutePath
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importCoverAlbums(
|
||||||
|
root: File,
|
||||||
|
albums: List<ThemePackageCoverAlbum>,
|
||||||
|
importedIds: MutableList<String>,
|
||||||
|
): Map<String, String> {
|
||||||
|
val existingNames = coverAlbumUseCase.albums.value.mapTo(mutableSetOf()) { it.name }
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
val albumRefs = mutableSetOf<String>()
|
||||||
|
albums.forEach { album ->
|
||||||
|
require(album.ref.isNotBlank()) { "主题包图集标识为空" }
|
||||||
|
require(albumRefs.add(album.ref)) { "主题包图集标识重复: ${album.ref}" }
|
||||||
|
}
|
||||||
|
albums.forEach { album ->
|
||||||
|
fun toInputs(images: List<ThemePackageCoverImage>) = images.map { image ->
|
||||||
|
val source = resolvePackageFile(root, image.path)
|
||||||
|
require(source.isFile) { "图集图片不存在: ${image.path}" }
|
||||||
|
CoverAlbumImageInput(
|
||||||
|
displayName = source.name,
|
||||||
|
openStream = source::inputStream,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val importedName = uniqueImportedAlbumName(album.name, existingNames)
|
||||||
|
val albumId = coverAlbumUseCase.importAlbum(
|
||||||
|
name = importedName,
|
||||||
|
lightImages = toInputs(album.lightImages),
|
||||||
|
darkImages = toInputs(album.darkImages),
|
||||||
|
)
|
||||||
|
importedIds += albumId
|
||||||
|
result[album.ref] = albumId
|
||||||
|
existingNames += importedName
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importLegacyCoverAlbums() {
|
||||||
|
val importedIds = mutableListOf<String>()
|
||||||
|
try {
|
||||||
|
val lightFiles = CoverConfig.defaultCover.toExistingFiles()
|
||||||
|
val darkFiles = CoverConfig.defaultCoverDark.toExistingFiles()
|
||||||
|
val albumId = if (lightFiles.isNotEmpty() || darkFiles.isNotEmpty()) {
|
||||||
|
val existingNames = coverAlbumUseCase.albums.value.mapTo(mutableSetOf()) { it.name }
|
||||||
|
val name = uniqueImportedAlbumName(
|
||||||
|
context.getString(R.string.default_cover),
|
||||||
|
existingNames,
|
||||||
|
)
|
||||||
|
coverAlbumUseCase.importAlbum(
|
||||||
|
name = name,
|
||||||
|
lightImages = lightFiles.toInputs(),
|
||||||
|
darkImages = darkFiles.toInputs(),
|
||||||
|
).also {
|
||||||
|
importedIds += it
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
coverAlbumUseCase.selectAlbum(albumId)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
importedIds.forEach { id ->
|
||||||
|
runCatching { coverAlbumUseCase.deleteAlbum(id) }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPackage(uri: Uri, root: File) {
|
||||||
|
root.mkdirs()
|
||||||
|
var entryCount = 0
|
||||||
|
var totalBytes = 0L
|
||||||
|
val entryNames = mutableSetOf<String>()
|
||||||
|
val input = context.contentResolver.openInputStream(uri)
|
||||||
|
?: error("无法读取主题包")
|
||||||
|
ZipInputStream(BufferedInputStream(input)).use { zip ->
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
entryCount++
|
||||||
|
require(entryCount <= MAX_ENTRY_COUNT) { "主题包文件数量过多" }
|
||||||
|
require(entryNames.add(entry.name.removeSuffix("/"))) {
|
||||||
|
"主题包包含重复路径: ${entry.name}"
|
||||||
|
}
|
||||||
|
val target = resolvePackageFile(root, entry.name)
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
target.mkdirs()
|
||||||
|
} else {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(target).use { output ->
|
||||||
|
totalBytes += zip.copyLimitedTo(
|
||||||
|
output = output,
|
||||||
|
maxEntryBytes = MAX_ENTRY_BYTES,
|
||||||
|
currentTotalBytes = totalBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
require(totalBytes <= MAX_TOTAL_BYTES) { "主题包解压后体积过大" }
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(entryCount > 0) { "主题包为空" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePackageFile(root: File, relativePath: String): File {
|
||||||
|
val normalizedPath = relativePath.removeSuffix("/")
|
||||||
|
require(normalizedPath.isNotBlank()) { "主题包包含空路径" }
|
||||||
|
require(!normalizedPath.startsWith("/") && '\\' !in normalizedPath) {
|
||||||
|
"主题包路径无效: $relativePath"
|
||||||
|
}
|
||||||
|
require(normalizedPath.split('/').none { it.isBlank() || it == "." || it == ".." }) {
|
||||||
|
"主题包路径无效: $relativePath"
|
||||||
|
}
|
||||||
|
val canonicalRoot = root.canonicalFile
|
||||||
|
val target = File(canonicalRoot, normalizedPath).canonicalFile
|
||||||
|
require(target.path.startsWith(canonicalRoot.path + File.separator)) {
|
||||||
|
"主题包路径越界: $relativePath"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAlbumRef(
|
||||||
|
albumRef: String?,
|
||||||
|
albumIdMap: Map<String, String>,
|
||||||
|
): String? {
|
||||||
|
if (albumRef == null) return null
|
||||||
|
return requireNotNull(albumIdMap[albumRef]) {
|
||||||
|
"主题包引用了不存在的图集: $albumRef"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueImportedAlbumName(
|
||||||
|
originalName: String,
|
||||||
|
existingNames: Set<String>,
|
||||||
|
): String {
|
||||||
|
val original = originalName.ifBlank { context.getString(R.string.cover_albums) }
|
||||||
|
if (original !in existingNames) return original
|
||||||
|
val baseName = context.getString(
|
||||||
|
R.string.cover_album_imported_name,
|
||||||
|
original,
|
||||||
|
)
|
||||||
|
if (baseName !in existingNames) return baseName
|
||||||
|
var index = 2
|
||||||
|
while ("$baseName $index" in existingNames) index++
|
||||||
|
return "$baseName $index"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ThemeExportData.toPortableConfig() = copy(
|
||||||
|
bgImageLight = null,
|
||||||
|
bgImageDark = null,
|
||||||
|
navIconHome = "",
|
||||||
|
navIconBookshelf = "",
|
||||||
|
navIconExplore = "",
|
||||||
|
navIconRss = "",
|
||||||
|
navIconMy = "",
|
||||||
|
appFontPath = null,
|
||||||
|
coverDefaultImage = "",
|
||||||
|
coverDefaultImageDark = "",
|
||||||
|
assets = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun openSource(path: String): InputStream {
|
||||||
|
return if (path.startsWith("content://")) {
|
||||||
|
context.contentResolver.openInputStream(Uri.parse(path))
|
||||||
|
?: error("无法读取主题资源")
|
||||||
|
} else {
|
||||||
|
File(path).inputStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sourceExtension(path: String, key: String): String {
|
||||||
|
val candidate = Uri.parse(path).lastPathSegment
|
||||||
|
?.substringAfterLast('.', "")
|
||||||
|
?.lowercase()
|
||||||
|
?.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
|
||||||
|
return candidate ?: if (key == ASSET_FONT) "ttf" else "img"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipOutputStream.writeEntry(path: String, input: InputStream) {
|
||||||
|
putNextEntry(ZipEntry(path))
|
||||||
|
input.use { it.copyTo(this) }
|
||||||
|
closeEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.copyLimitedTo(
|
||||||
|
output: OutputStream,
|
||||||
|
maxEntryBytes: Long,
|
||||||
|
currentTotalBytes: Long,
|
||||||
|
): Long {
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var entryBytes = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
entryBytes += read
|
||||||
|
require(entryBytes <= maxEntryBytes) { "主题包单个文件过大" }
|
||||||
|
require(currentTotalBytes + entryBytes <= MAX_TOTAL_BYTES) {
|
||||||
|
"主题包解压后体积过大"
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
return entryBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toExistingFiles(): List<File> =
|
||||||
|
split(",")
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.map(::File)
|
||||||
|
.filter(File::isFile)
|
||||||
|
|
||||||
|
private fun List<File>.toInputs(): List<CoverAlbumImageInput> = map { file ->
|
||||||
|
CoverAlbumImageInput(
|
||||||
|
displayName = file.name,
|
||||||
|
openStream = file::inputStream,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> runSuspendCatching(
|
||||||
|
crossinline block: suspend () -> T,
|
||||||
|
): Result<T> {
|
||||||
|
return try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AssetSource(
|
||||||
|
val key: String,
|
||||||
|
val sourcePath: String?,
|
||||||
|
val entryBase: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ExportedCoverData(
|
||||||
|
val albums: List<ThemePackageCoverAlbum>,
|
||||||
|
val selection: ThemePackageCoverSelection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class ThemePackageManifest(
|
||||||
|
@SerializedName("formatVersion")
|
||||||
|
val formatVersion: Int = 1,
|
||||||
|
@SerializedName("name")
|
||||||
|
val name: String? = null,
|
||||||
|
@SerializedName("config")
|
||||||
|
val config: ThemeExportData = ThemeExportData(),
|
||||||
|
@SerializedName("assets")
|
||||||
|
val assets: Map<String, String> = emptyMap(),
|
||||||
|
@SerializedName("coverAlbums")
|
||||||
|
val coverAlbums: List<ThemePackageCoverAlbum> = emptyList(),
|
||||||
|
@SerializedName("coverSelection")
|
||||||
|
val coverSelection: ThemePackageCoverSelection = ThemePackageCoverSelection(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class ThemePackageCoverAlbum(
|
||||||
|
@SerializedName("ref")
|
||||||
|
val ref: String = "",
|
||||||
|
@SerializedName("name")
|
||||||
|
val name: String = "",
|
||||||
|
@SerializedName("lightImages")
|
||||||
|
val lightImages: List<ThemePackageCoverImage> = emptyList(),
|
||||||
|
@SerializedName("darkImages")
|
||||||
|
val darkImages: List<ThemePackageCoverImage> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class ThemePackageCoverImage(
|
||||||
|
@SerializedName("path")
|
||||||
|
val path: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class ThemePackageCoverSelection(
|
||||||
|
@SerializedName("albumRef")
|
||||||
|
val albumRef: String? = null,
|
||||||
|
)
|
||||||
@@ -32,28 +32,31 @@ import io.legado.app.help.glide.OkHttpModelLoader
|
|||||||
import io.legado.app.model.analyzeRule.AnalyzeRule
|
import io.legado.app.model.analyzeRule.AnalyzeRule
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
||||||
|
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||||
import io.legado.app.utils.BitmapUtils
|
import io.legado.app.utils.BitmapUtils
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.utils.GSON
|
||||||
import io.legado.app.utils.fromJsonObject
|
import io.legado.app.utils.fromJsonObject
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
object BookCover {
|
object BookCover : KoinComponent {
|
||||||
|
|
||||||
private const val coverRuleConfigKey = "legadoCoverRuleConfig"
|
private const val coverRuleConfigKey = "legadoCoverRuleConfig"
|
||||||
const val configFileName = "coverRule.json"
|
const val configFileName = "coverRule.json"
|
||||||
|
private val coverAlbumUseCase: CoverAlbumUseCase by inject()
|
||||||
|
|
||||||
val defaultDrawable: Drawable
|
val defaultDrawable: Drawable
|
||||||
@SuppressLint("UseCompatLoadingForDrawables")
|
@SuppressLint("UseCompatLoadingForDrawables")
|
||||||
get() {
|
get() {
|
||||||
val isNightTheme = AppConfig.isNightTheme
|
val isNightTheme = AppConfig.isNightTheme
|
||||||
val pathStr = if (isNightTheme) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
val paths = coverAlbumUseCase.selectedImagePaths(isNightTheme)
|
||||||
val paths = pathStr.split(",").filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
if (paths.isNullOrEmpty()) {
|
if (paths.isEmpty()) {
|
||||||
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +70,8 @@ object BookCover {
|
|||||||
seed: Any? = null,
|
seed: Any? = null,
|
||||||
isNight: Boolean = AppConfig.isNightTheme
|
isNight: Boolean = AppConfig.isNightTheme
|
||||||
): String? {
|
): String? {
|
||||||
val pathStr = if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
val paths = coverAlbumUseCase.selectedImagePaths(isNight)
|
||||||
val paths = pathStr.split(",").filter { it.isNotBlank() }
|
if (paths.isEmpty()) return null
|
||||||
if (paths.isNullOrEmpty()) return null
|
|
||||||
val random = if (seed != null) Random(seed.hashCode()) else Random
|
val random = if (seed != null) Random(seed.hashCode()) else Random
|
||||||
return paths[random.nextInt(paths.size)]
|
return paths[random.nextInt(paths.size)]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package io.legado.app.ui.config.coverConfig
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class CoverAlbumItemUi(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val lightImages: ImmutableList<CoverAlbumImageUi> = persistentListOf(),
|
||||||
|
val darkImages: ImmutableList<CoverAlbumImageUi> = persistentListOf(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class CoverAlbumImageUi(
|
||||||
|
val id: String,
|
||||||
|
val path: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class CoverAlbumSelectionUiState(
|
||||||
|
val albums: ImmutableList<CoverAlbumItemUi> = persistentListOf(),
|
||||||
|
val selectedAlbumId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class CoverAlbumManageUiState(
|
||||||
|
val albums: ImmutableList<CoverAlbumItemUi> = persistentListOf(),
|
||||||
|
val selectedAlbumId: String? = null,
|
||||||
|
val editingAlbumId: String? = null,
|
||||||
|
val dialog: CoverAlbumDialog? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface CoverAlbumIntent {
|
||||||
|
data object CreateClick : CoverAlbumIntent
|
||||||
|
data class EditClick(val albumId: String) : CoverAlbumIntent
|
||||||
|
data class RenameClick(val albumId: String) : CoverAlbumIntent
|
||||||
|
data class DeleteClick(val albumId: String) : CoverAlbumIntent
|
||||||
|
data class SaveName(val name: String) : CoverAlbumIntent
|
||||||
|
data object ConfirmDelete : CoverAlbumIntent
|
||||||
|
data class AddImagesClick(val albumId: String, val isDark: Boolean) : CoverAlbumIntent
|
||||||
|
data class ImagesSelected(
|
||||||
|
val albumId: String,
|
||||||
|
val isDark: Boolean,
|
||||||
|
val uriStrings: List<String>,
|
||||||
|
) : CoverAlbumIntent
|
||||||
|
|
||||||
|
data class RemoveImage(
|
||||||
|
val albumId: String,
|
||||||
|
val isDark: Boolean,
|
||||||
|
val imageId: String,
|
||||||
|
) : CoverAlbumIntent
|
||||||
|
|
||||||
|
data object DismissEditor : CoverAlbumIntent
|
||||||
|
data object DismissDialog : CoverAlbumIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CoverAlbumEffect {
|
||||||
|
data class SelectImages(val albumId: String, val isDark: Boolean) : CoverAlbumEffect
|
||||||
|
data class ShowMessage(val message: String) : CoverAlbumEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CoverAlbumDialog {
|
||||||
|
data object Create : CoverAlbumDialog
|
||||||
|
data class Rename(val albumId: String, val currentName: String) : CoverAlbumDialog
|
||||||
|
data class Delete(val albumId: String, val name: String) : CoverAlbumDialog
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package io.legado.app.ui.config.coverConfig
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Collections
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||||
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
|
import io.legado.app.ui.widget.components.AppTextField
|
||||||
|
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||||
|
import io.legado.app.ui.widget.components.button.AppIconButton
|
||||||
|
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
|
||||||
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
import io.legado.app.ui.widget.components.card.NormalCard
|
||||||
|
import io.legado.app.ui.widget.components.icon.AppIcon
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
|
import io.legado.app.ui.widget.components.tabRow.CardTabRow
|
||||||
|
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.TopBarActionButton
|
||||||
|
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
||||||
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CoverAlbumManageRouteScreen(
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
viewModel: CoverAlbumManageViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var imageTargetAlbumId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var imageTargetIsDark by remember { mutableStateOf(false) }
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
val albumId = imageTargetAlbumId
|
||||||
|
imageTargetAlbumId = null
|
||||||
|
if (albumId != null && uris.isNotEmpty()) {
|
||||||
|
viewModel.onIntent(
|
||||||
|
CoverAlbumIntent.ImagesSelected(
|
||||||
|
albumId = albumId,
|
||||||
|
isDark = imageTargetIsDark,
|
||||||
|
uriStrings = uris.map { it.toString() },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collectLatest { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is CoverAlbumEffect.SelectImages -> {
|
||||||
|
imageTargetAlbumId = effect.albumId
|
||||||
|
imageTargetIsDark = effect.isDark
|
||||||
|
imagePicker.launch("image/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
is CoverAlbumEffect.ShowMessage -> context.toastOnUi(effect.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoverAlbumManageScreen(
|
||||||
|
state = state,
|
||||||
|
onIntent = viewModel::onIntent,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CoverAlbumManageScreen(
|
||||||
|
state: CoverAlbumManageUiState,
|
||||||
|
onIntent: (CoverAlbumIntent) -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
|
AppScaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
GlassMediumFlexibleTopAppBar(
|
||||||
|
title = stringResource(R.string.cover_albums),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
navigationIcon = {
|
||||||
|
TopBarNavigationButton(onClick = onBackClick)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = { onIntent(CoverAlbumIntent.CreateClick) },
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.cover_album_create),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
if (state.albums.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = Icons.Outlined.Collections,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
AppText(
|
||||||
|
text = stringResource(R.string.cover_album_empty),
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = adaptiveContentPadding(
|
||||||
|
top = paddingValues.calculateTopPadding() + 8.dp,
|
||||||
|
bottom = 120.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.albums,
|
||||||
|
key = { it.id },
|
||||||
|
contentType = { "coverAlbum" },
|
||||||
|
) { album ->
|
||||||
|
CoverAlbumCard(
|
||||||
|
album = album,
|
||||||
|
selected = state.selectedAlbumId == album.id,
|
||||||
|
onClick = { onIntent(CoverAlbumIntent.EditClick(album.id)) },
|
||||||
|
onRename = { onIntent(CoverAlbumIntent.RenameClick(album.id)) },
|
||||||
|
onDelete = { onIntent(CoverAlbumIntent.DeleteClick(album.id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val editingAlbum = state.albums.firstOrNull { it.id == state.editingAlbumId }
|
||||||
|
CoverAlbumEditorSheet(
|
||||||
|
album = editingAlbum,
|
||||||
|
onAddImages = { isDark ->
|
||||||
|
editingAlbum?.let {
|
||||||
|
onIntent(CoverAlbumIntent.AddImagesClick(it.id, isDark))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoveImage = { isDark, imageId ->
|
||||||
|
editingAlbum?.let {
|
||||||
|
onIntent(CoverAlbumIntent.RemoveImage(it.id, isDark, imageId))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = { onIntent(CoverAlbumIntent.DismissEditor) },
|
||||||
|
)
|
||||||
|
|
||||||
|
CoverAlbumDialogs(
|
||||||
|
dialog = state.dialog,
|
||||||
|
onIntent = onIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverAlbumCard(
|
||||||
|
album: CoverAlbumItemUi,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
GlassCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 64.dp, height = 88.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val preview = album.lightImages.firstOrNull()?.path
|
||||||
|
?: album.darkImages.firstOrNull()?.path
|
||||||
|
if (preview != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = preview,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = Icons.Outlined.Collections,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
AppText(
|
||||||
|
text = album.name,
|
||||||
|
style = LegadoTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
AppText(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.cover_album_day_night_count,
|
||||||
|
album.lightImages.size,
|
||||||
|
album.darkImages.size,
|
||||||
|
),
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
AppText(
|
||||||
|
text = stringResource(R.string.cover_album_selected),
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = LegadoTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
SmallPlainButton(
|
||||||
|
onClick = onRename,
|
||||||
|
icon = Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.cover_album_rename),
|
||||||
|
)
|
||||||
|
SmallPlainButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
icon = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.cover_album_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverAlbumEditorSheet(
|
||||||
|
album: CoverAlbumItemUi?,
|
||||||
|
onAddImages: (Boolean) -> Unit,
|
||||||
|
onRemoveImage: (Boolean, String) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
var selectedTab by remember(album?.id) { mutableIntStateOf(0) }
|
||||||
|
val isDark = selectedTab == 1
|
||||||
|
AppModalBottomSheet(
|
||||||
|
show = album != null,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = album?.name,
|
||||||
|
endAction = {
|
||||||
|
SmallPlainButton(
|
||||||
|
onClick = { onAddImages(isDark) },
|
||||||
|
icon = Icons.Default.Add,
|
||||||
|
text = stringResource(R.string.cover_album_add_images),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
CardTabRow(
|
||||||
|
tabTitles = listOf(
|
||||||
|
stringResource(R.string.day),
|
||||||
|
stringResource(R.string.night),
|
||||||
|
),
|
||||||
|
selectedTabIndex = selectedTab,
|
||||||
|
onTabSelected = { selectedTab = it },
|
||||||
|
)
|
||||||
|
val images = if (isDark) album?.darkImages.orEmpty() else album?.lightImages.orEmpty()
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 560.dp)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = images,
|
||||||
|
key = { it.id },
|
||||||
|
contentType = { "coverAlbumImage" },
|
||||||
|
) { image ->
|
||||||
|
Box {
|
||||||
|
NormalCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(5f / 7f),
|
||||||
|
cornerRadius = 12.dp,
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = image.path,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppIconButton(
|
||||||
|
onClick = { onRemoveImage(isDark, image.id) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(28.dp),
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(
|
||||||
|
alpha = 0.82f
|
||||||
|
)
|
||||||
|
),
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.delete),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverAlbumDialogs(
|
||||||
|
dialog: CoverAlbumDialog?,
|
||||||
|
onIntent: (CoverAlbumIntent) -> Unit,
|
||||||
|
) {
|
||||||
|
when (dialog) {
|
||||||
|
CoverAlbumDialog.Create -> CoverAlbumNameDialog(
|
||||||
|
show = true,
|
||||||
|
title = stringResource(R.string.cover_album_create),
|
||||||
|
initialName = "",
|
||||||
|
onSave = { onIntent(CoverAlbumIntent.SaveName(it)) },
|
||||||
|
onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
|
||||||
|
)
|
||||||
|
|
||||||
|
is CoverAlbumDialog.Rename -> CoverAlbumNameDialog(
|
||||||
|
show = true,
|
||||||
|
title = stringResource(R.string.cover_album_rename),
|
||||||
|
initialName = dialog.currentName,
|
||||||
|
onSave = { onIntent(CoverAlbumIntent.SaveName(it)) },
|
||||||
|
onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
|
||||||
|
)
|
||||||
|
|
||||||
|
is CoverAlbumDialog.Delete -> AppAlertDialog(
|
||||||
|
show = true,
|
||||||
|
onDismissRequest = { onIntent(CoverAlbumIntent.DismissDialog) },
|
||||||
|
title = stringResource(R.string.cover_album_delete),
|
||||||
|
text = stringResource(R.string.cover_album_delete_confirmation, dialog.name),
|
||||||
|
confirmText = stringResource(R.string.delete),
|
||||||
|
onConfirm = { onIntent(CoverAlbumIntent.ConfirmDelete) },
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
|
||||||
|
)
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverAlbumNameDialog(
|
||||||
|
show: Boolean,
|
||||||
|
title: String,
|
||||||
|
initialName: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember(initialName, show) { mutableStateOf(initialName) }
|
||||||
|
AppAlertDialog(
|
||||||
|
show = show,
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = title,
|
||||||
|
confirmText = stringResource(R.string.ok),
|
||||||
|
onConfirm = {
|
||||||
|
if (name.isNotBlank()) onSave(name)
|
||||||
|
},
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
content = {
|
||||||
|
AppTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
placeholder = { AppText(stringResource(R.string.cover_album_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package io.legado.app.ui.config.coverConfig
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.domain.model.CoverAlbumImageInput
|
||||||
|
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CoverAlbumManageViewModel(
|
||||||
|
private val context: Context,
|
||||||
|
private val coverAlbumUseCase: CoverAlbumUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val editingAlbumId = MutableStateFlow<String?>(null)
|
||||||
|
private val dialog = MutableStateFlow<CoverAlbumDialog?>(null)
|
||||||
|
private val _effects = MutableSharedFlow<CoverAlbumEffect>(extraBufferCapacity = 8)
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
|
val uiState = combine(
|
||||||
|
coverAlbumUseCase.albums,
|
||||||
|
coverAlbumUseCase.selection,
|
||||||
|
editingAlbumId,
|
||||||
|
dialog,
|
||||||
|
) { albums, selection, editingId, activeDialog ->
|
||||||
|
CoverAlbumManageUiState(
|
||||||
|
albums = albums.map { it.toUi() }.toImmutableList(),
|
||||||
|
selectedAlbumId = selection.albumId,
|
||||||
|
editingAlbumId = editingId,
|
||||||
|
dialog = activeDialog,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = CoverAlbumManageUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onIntent(intent: CoverAlbumIntent) {
|
||||||
|
when (intent) {
|
||||||
|
CoverAlbumIntent.CreateClick -> dialog.value = CoverAlbumDialog.Create
|
||||||
|
is CoverAlbumIntent.EditClick -> editingAlbumId.value = intent.albumId
|
||||||
|
is CoverAlbumIntent.RenameClick -> {
|
||||||
|
val album = uiState.value.albums.firstOrNull { it.id == intent.albumId } ?: return
|
||||||
|
dialog.value = CoverAlbumDialog.Rename(album.id, album.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CoverAlbumIntent.DeleteClick -> {
|
||||||
|
val album = uiState.value.albums.firstOrNull { it.id == intent.albumId } ?: return
|
||||||
|
dialog.value = CoverAlbumDialog.Delete(album.id, album.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CoverAlbumIntent.SaveName -> saveName(intent.name)
|
||||||
|
CoverAlbumIntent.ConfirmDelete -> confirmDelete()
|
||||||
|
is CoverAlbumIntent.AddImagesClick -> {
|
||||||
|
_effects.tryEmit(
|
||||||
|
CoverAlbumEffect.SelectImages(intent.albumId, intent.isDark)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CoverAlbumIntent.ImagesSelected -> addImages(
|
||||||
|
albumId = intent.albumId,
|
||||||
|
isDark = intent.isDark,
|
||||||
|
uriStrings = intent.uriStrings,
|
||||||
|
)
|
||||||
|
|
||||||
|
is CoverAlbumIntent.RemoveImage -> removeImage(
|
||||||
|
albumId = intent.albumId,
|
||||||
|
isDark = intent.isDark,
|
||||||
|
imageId = intent.imageId,
|
||||||
|
)
|
||||||
|
|
||||||
|
CoverAlbumIntent.DismissEditor -> editingAlbumId.value = null
|
||||||
|
CoverAlbumIntent.DismissDialog -> dialog.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveName(name: String) {
|
||||||
|
val trimmedName = name.trim()
|
||||||
|
if (trimmedName.isEmpty()) return
|
||||||
|
val activeDialog = dialog.value
|
||||||
|
dialog.value = null
|
||||||
|
launchOperation {
|
||||||
|
when (activeDialog) {
|
||||||
|
CoverAlbumDialog.Create -> {
|
||||||
|
val id = coverAlbumUseCase.createAlbum(trimmedName)
|
||||||
|
editingAlbumId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
is CoverAlbumDialog.Rename -> {
|
||||||
|
coverAlbumUseCase.renameAlbum(activeDialog.albumId, trimmedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmDelete() {
|
||||||
|
val activeDialog = dialog.value as? CoverAlbumDialog.Delete ?: return
|
||||||
|
dialog.value = null
|
||||||
|
editingAlbumId.update { id -> if (id == activeDialog.albumId) null else id }
|
||||||
|
launchOperation {
|
||||||
|
coverAlbumUseCase.deleteAlbum(activeDialog.albumId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addImages(albumId: String, isDark: Boolean, uriStrings: List<String>) {
|
||||||
|
if (uriStrings.isEmpty()) return
|
||||||
|
launchOperation {
|
||||||
|
val inputs = uriStrings.map { uriString ->
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
CoverAlbumImageInput(
|
||||||
|
displayName = queryDisplayName(uri),
|
||||||
|
openStream = {
|
||||||
|
context.contentResolver.openInputStream(uri)
|
||||||
|
?: error("无法读取图片")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coverAlbumUseCase.addImages(albumId, isDark, inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeImage(albumId: String, isDark: Boolean, imageId: String) {
|
||||||
|
launchOperation {
|
||||||
|
coverAlbumUseCase.removeImage(albumId, isDark, imageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryDisplayName(uri: Uri): String {
|
||||||
|
return context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
|
} ?: "cover_image"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchOperation(block: suspend () -> Unit) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_effects.emit(
|
||||||
|
CoverAlbumEffect.ShowMessage(
|
||||||
|
error.localizedMessage ?: error.javaClass.simpleName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package io.legado.app.ui.config.coverConfig
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.outlined.Collections
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
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.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
|
||||||
|
import io.legado.app.ui.widget.components.card.NormalCard
|
||||||
|
import io.legado.app.ui.widget.components.icon.AppIcon
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CoverAlbumSelectSheet(
|
||||||
|
show: Boolean,
|
||||||
|
state: CoverAlbumSelectionUiState,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
onManage: () -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedId = state.selectedAlbumId
|
||||||
|
AppModalBottomSheet(
|
||||||
|
show = show,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = stringResource(R.string.select_cover_album),
|
||||||
|
endAction = {
|
||||||
|
SmallPlainButton(
|
||||||
|
onClick = onManage,
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
contentDescription = stringResource(R.string.manage_cover_albums),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
CoverAlbumSelectionItem(
|
||||||
|
name = stringResource(R.string.cover_album_none),
|
||||||
|
imagePath = null,
|
||||||
|
lightImageCount = null,
|
||||||
|
darkImageCount = null,
|
||||||
|
selected = selectedId == null,
|
||||||
|
onClick = {
|
||||||
|
onSelect(null)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.albums.forEach { album ->
|
||||||
|
CoverAlbumSelectionItem(
|
||||||
|
name = album.name,
|
||||||
|
imagePath = album.lightImages.firstOrNull()?.path
|
||||||
|
?: album.darkImages.firstOrNull()?.path,
|
||||||
|
lightImageCount = album.lightImages.size,
|
||||||
|
darkImageCount = album.darkImages.size,
|
||||||
|
selected = selectedId == album.id,
|
||||||
|
onClick = {
|
||||||
|
onSelect(album.id)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverAlbumSelectionItem(
|
||||||
|
name: String,
|
||||||
|
imagePath: String?,
|
||||||
|
lightImageCount: Int?,
|
||||||
|
darkImageCount: Int?,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
NormalCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
cornerRadius = 16.dp,
|
||||||
|
containerColor = if (selected) {
|
||||||
|
LegadoTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
LegadoTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 44.dp, height = 60.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (imagePath != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imagePath,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = Icons.Outlined.Collections,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
AppText(
|
||||||
|
text = name,
|
||||||
|
style = LegadoTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
if (lightImageCount != null && darkImageCount != null) {
|
||||||
|
AppText(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.cover_album_day_night_count,
|
||||||
|
lightImageCount,
|
||||||
|
darkImageCount,
|
||||||
|
),
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LegadoTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.constant.PreferKey
|
|
||||||
import io.legado.app.ui.theme.adaptiveContentPadding
|
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||||
import io.legado.app.ui.widget.components.AppScaffold
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
||||||
@@ -38,11 +38,15 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
@Composable
|
@Composable
|
||||||
fun CoverConfigScreen(
|
fun CoverConfigScreen(
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
onNavigateToCoverAlbums: () -> Unit,
|
||||||
viewModel: CoverConfigViewModel = koinViewModel()
|
viewModel: CoverConfigViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
|
val albumState by viewModel.albumState.collectAsStateWithLifecycle()
|
||||||
|
val selectedAlbum = albumState.albums
|
||||||
|
.firstOrNull { it.id == albumState.selectedAlbumId }
|
||||||
var showCoverRuleSheet by remember { mutableStateOf(false) }
|
var showCoverRuleSheet by remember { mutableStateOf(false) }
|
||||||
var manageKey by remember { mutableStateOf<String?>(null) }
|
var showAlbumSelect by remember { mutableStateOf(false) }
|
||||||
var showColorPickerByField by remember { mutableStateOf<String?>(null) }
|
var showColorPickerByField by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
@@ -86,6 +90,20 @@ fun CoverConfigScreen(
|
|||||||
onCheckedChange = { CoverConfig.useDefaultCover = it }
|
onCheckedChange = { CoverConfig.useDefaultCover = it }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ClickableSettingItem(
|
||||||
|
title = stringResource(R.string.default_cover),
|
||||||
|
description = selectedAlbum?.let {
|
||||||
|
"${it.name} · ${
|
||||||
|
stringResource(
|
||||||
|
R.string.cover_album_day_night_count,
|
||||||
|
it.lightImages.size,
|
||||||
|
it.darkImages.size,
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
} ?: stringResource(R.string.cover_album_none),
|
||||||
|
onClick = { showAlbumSelect = true }
|
||||||
|
)
|
||||||
|
|
||||||
SwitchSettingItem(
|
SwitchSettingItem(
|
||||||
title = stringResource(R.string.cover_show_shadow),
|
title = stringResource(R.string.cover_show_shadow),
|
||||||
checked = CoverConfig.coverShowShadow,
|
checked = CoverConfig.coverShowShadow,
|
||||||
@@ -130,35 +148,24 @@ fun CoverConfigScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
|
SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
|
||||||
DropdownListSettingItem(
|
DropdownListSettingItem(
|
||||||
title = stringResource(R.string.network_book_badge_setting),
|
title = stringResource(R.string.network_book_badge_setting),
|
||||||
selectedValue = CoverConfig.exploreFilterState.toString(),
|
selectedValue = CoverConfig.exploreFilterState.toString(),
|
||||||
displayEntries = arrayOf(
|
displayEntries = arrayOf(
|
||||||
stringResource(R.string.filter_show_all),
|
stringResource(R.string.filter_show_all),
|
||||||
stringResource(R.string.filter_hide_in_shelf),
|
stringResource(R.string.filter_hide_in_shelf),
|
||||||
stringResource(R.string.filter_hide_same_name_author),
|
stringResource(R.string.filter_hide_same_name_author),
|
||||||
stringResource(R.string.filter_show_not_in_shelf_only)
|
stringResource(R.string.filter_show_not_in_shelf_only)
|
||||||
),
|
),
|
||||||
entryValues = arrayOf("0", "1", "2", "3"),
|
entryValues = arrayOf("0", "1", "2", "3"),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
CoverConfig.exploreFilterState = it.toInt()
|
CoverConfig.exploreFilterState = it.toInt()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SplicedColumnGroup(title = stringResource(R.string.day)) {
|
SplicedColumnGroup(title = stringResource(R.string.day)) {
|
||||||
val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size
|
|
||||||
ClickableSettingItem(
|
|
||||||
title = stringResource(R.string.default_cover),
|
|
||||||
description = if (coverCount > 0) {
|
|
||||||
stringResource(R.string.cover_selected_images_count, coverCount)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.select_image)
|
|
||||||
},
|
|
||||||
onClick = { manageKey = PreferKey.defaultCover }
|
|
||||||
)
|
|
||||||
|
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.text_color),
|
title = stringResource(R.string.text_color),
|
||||||
option = "#${Integer.toHexString(CoverConfig.coverTextColor).uppercase()}",
|
option = "#${Integer.toHexString(CoverConfig.coverTextColor).uppercase()}",
|
||||||
@@ -205,19 +212,7 @@ fun CoverConfigScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SplicedColumnGroup(title = stringResource(R.string.night)) {
|
SplicedColumnGroup(title = stringResource(R.string.night)) {
|
||||||
val coverCount =
|
|
||||||
CoverConfig.defaultCoverDark.split(",").filter { it.isNotBlank() }.size
|
|
||||||
ClickableSettingItem(
|
|
||||||
title = stringResource(R.string.default_cover),
|
|
||||||
description = if (coverCount > 0) {
|
|
||||||
stringResource(R.string.cover_selected_images_count, coverCount)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.select_image)
|
|
||||||
},
|
|
||||||
onClick = { manageKey = PreferKey.defaultCoverDark }
|
|
||||||
)
|
|
||||||
|
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.text_color),
|
title = stringResource(R.string.text_color),
|
||||||
option = "#${Integer.toHexString(CoverConfig.coverTextColorN).uppercase()}",
|
option = "#${Integer.toHexString(CoverConfig.coverTextColorN).uppercase()}",
|
||||||
@@ -272,12 +267,16 @@ fun CoverConfigScreen(
|
|||||||
onDismissRequest = { showCoverRuleSheet = false }
|
onDismissRequest = { showCoverRuleSheet = false }
|
||||||
)
|
)
|
||||||
|
|
||||||
manageKey?.let { key ->
|
if (showAlbumSelect) {
|
||||||
CoverManageSheet(
|
CoverAlbumSelectSheet(
|
||||||
show = true,
|
show = true,
|
||||||
preferenceKey = key,
|
state = albumState,
|
||||||
onDismissRequest = { manageKey = null },
|
onSelect = viewModel::selectAlbum,
|
||||||
viewModel = viewModel
|
onManage = {
|
||||||
|
showAlbumSelect = false
|
||||||
|
onNavigateToCoverAlbums()
|
||||||
|
},
|
||||||
|
onDismissRequest = { showAlbumSelect = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,36 @@
|
|||||||
package io.legado.app.ui.config.coverConfig
|
package io.legado.app.ui.config.coverConfig
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import io.legado.app.constant.AppLog
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.constant.PreferKey
|
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||||
import io.legado.app.exception.NoStackTraceException
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import io.legado.app.lib.permission.Permissions
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.legado.app.lib.permission.PermissionsCompat
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import io.legado.app.utils.FileDoc
|
import kotlinx.coroutines.flow.combine
|
||||||
import io.legado.app.utils.FileUtils
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import io.legado.app.utils.MD5Utils
|
import kotlinx.coroutines.launch
|
||||||
import io.legado.app.utils.RealPathUtil
|
|
||||||
import io.legado.app.utils.externalFiles
|
|
||||||
import io.legado.app.utils.isContentScheme
|
|
||||||
import io.legado.app.utils.printOnDebug
|
|
||||||
import io.legado.app.utils.toastOnUi
|
|
||||||
import splitties.init.appCtx
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
class CoverConfigViewModel : ViewModel() {
|
class CoverConfigViewModel(
|
||||||
|
private val coverAlbumUseCase: CoverAlbumUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
fun addCoverFromUri(preferenceKey: String, uris: List<Uri>) {
|
val albumState = combine(
|
||||||
uris.forEach { uri ->
|
coverAlbumUseCase.albums,
|
||||||
readUri(appCtx, uri) { fileDoc, _ ->
|
coverAlbumUseCase.selection,
|
||||||
kotlin.runCatching {
|
) { albums, selection ->
|
||||||
var file = appCtx.externalFiles
|
CoverAlbumSelectionUiState(
|
||||||
val suffix = fileDoc.name.substringAfterLast(".")
|
albums = albums.map { it.toUi() }.toImmutableList(),
|
||||||
val inputStreamForMd5 = appCtx.contentResolver.openInputStream(uri)
|
selectedAlbumId = selection.albumId,
|
||||||
?: throw NoStackTraceException("无法打开输入流")
|
)
|
||||||
val fileName = MD5Utils.md5Encode(inputStreamForMd5) + ".$suffix"
|
}.stateIn(
|
||||||
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
|
scope = viewModelScope,
|
||||||
FileOutputStream(file).use {
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
appCtx.contentResolver.openInputStream(uri)?.use { input ->
|
initialValue = CoverAlbumSelectionUiState(),
|
||||||
input.copyTo(it)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
|
|
||||||
CoverConfig.defaultCover
|
|
||||||
} else {
|
|
||||||
CoverConfig.defaultCoverDark
|
|
||||||
}
|
|
||||||
val newList =
|
|
||||||
currentCovers.split(",").filter { it.isNotBlank() }.toMutableList()
|
|
||||||
if (!newList.contains(file.absolutePath)) {
|
|
||||||
newList.add(file.absolutePath)
|
|
||||||
}
|
|
||||||
val newCovers = newList.joinToString(",")
|
|
||||||
if (preferenceKey == PreferKey.defaultCover) {
|
|
||||||
CoverConfig.defaultCover = newCovers
|
|
||||||
} else {
|
|
||||||
CoverConfig.defaultCoverDark = newCovers
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
appCtx.toastOnUi(it.localizedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readUri(
|
fun selectAlbum(albumId: String?) {
|
||||||
context: Context,
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
uri: Uri?,
|
coverAlbumUseCase.selectAlbum(albumId)
|
||||||
success: (fileDoc: FileDoc, inputStream: InputStream) -> Unit
|
|
||||||
) {
|
|
||||||
uri ?: return
|
|
||||||
try {
|
|
||||||
if (uri.isContentScheme()) {
|
|
||||||
val doc = DocumentFile.fromSingleUri(context, uri)
|
|
||||||
doc ?: throw NoStackTraceException("未获取到文件")
|
|
||||||
val fileDoc = FileDoc.fromDocumentFile(doc)
|
|
||||||
context.contentResolver.openInputStream(uri)!!.use { inputStream ->
|
|
||||||
success.invoke(fileDoc, inputStream)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PermissionsCompat.Builder()
|
|
||||||
.addPermissions(*Permissions.Group.STORAGE)
|
|
||||||
.onGranted {
|
|
||||||
RealPathUtil.getPath(context, uri)?.let { path ->
|
|
||||||
val file = File(path)
|
|
||||||
val fileDoc = FileDoc.fromFile(file)
|
|
||||||
FileInputStream(file).use { inputStream ->
|
|
||||||
success.invoke(fileDoc, inputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.request()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printOnDebug()
|
|
||||||
AppLog.put("读取Uri出错\n$e", e, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeCover(preferenceKey: String, path: String) {
|
|
||||||
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
|
|
||||||
CoverConfig.defaultCover
|
|
||||||
} else {
|
|
||||||
CoverConfig.defaultCoverDark
|
|
||||||
}
|
|
||||||
val newList = currentCovers.split(",").filter { it.isNotBlank() && it != path }
|
|
||||||
val newCovers = newList.joinToString(",")
|
|
||||||
if (preferenceKey == PreferKey.defaultCover) {
|
|
||||||
CoverConfig.defaultCover = newCovers
|
|
||||||
} else {
|
|
||||||
CoverConfig.defaultCoverDark = newCovers
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,3 +54,20 @@ class CoverConfigViewModel : ViewModel() {
|
|||||||
// no-op: Compose CoilBookCover reads CoverConfig preferences directly
|
// no-op: Compose CoilBookCover reads CoverConfig preferences directly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun io.legado.app.domain.model.CoverAlbum.toUi() = CoverAlbumItemUi(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
lightImages = lightImages.map {
|
||||||
|
CoverAlbumImageUi(
|
||||||
|
id = it.id,
|
||||||
|
path = it.path,
|
||||||
|
)
|
||||||
|
}.toImmutableList(),
|
||||||
|
darkImages = darkImages.map {
|
||||||
|
CoverAlbumImageUi(
|
||||||
|
id = it.id,
|
||||||
|
path = it.path,
|
||||||
|
)
|
||||||
|
}.toImmutableList(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package io.legado.app.ui.config.coverConfig
|
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
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.constant.PreferKey
|
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
|
||||||
import io.legado.app.ui.widget.components.card.NormalCard
|
|
||||||
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
|
|
||||||
import io.legado.app.ui.widget.components.icon.AppIcon
|
|
||||||
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
|
||||||
import io.legado.app.ui.widget.components.text.AppText
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CoverManageSheet(
|
|
||||||
show: Boolean,
|
|
||||||
preferenceKey: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
viewModel: CoverConfigViewModel
|
|
||||||
) {
|
|
||||||
var showFilePicker by remember { mutableStateOf(false) }
|
|
||||||
val selectImages =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
|
||||||
if (uris.isNotEmpty()) {
|
|
||||||
viewModel.addCoverFromUri(preferenceKey, uris)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
|
|
||||||
CoverConfig.defaultCover
|
|
||||||
} else {
|
|
||||||
CoverConfig.defaultCoverDark
|
|
||||||
}
|
|
||||||
val coverList = currentCovers.split(",").filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
AppModalBottomSheet(
|
|
||||||
show = show,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
title = stringResource(R.string.default_cover)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 24.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(3),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
items(coverList) { path ->
|
|
||||||
Box {
|
|
||||||
NormalCard(
|
|
||||||
cornerRadius = 12.dp,
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = path,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(0.7f),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { viewModel.removeCover(preferenceKey, path) },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(4.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = CircleShape,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = "Remove",
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
NormalCard(
|
|
||||||
onClick = { showFilePicker = true },
|
|
||||||
cornerRadius = 12.dp,
|
|
||||||
containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(0.7f)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
AppIcon(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = "Add",
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FilePickerSheet(
|
|
||||||
show = showFilePicker,
|
|
||||||
onDismissRequest = { showFilePicker = false },
|
|
||||||
onSelectSysFiles = {
|
|
||||||
selectImages.launch("image/*")
|
|
||||||
showFilePicker = false
|
|
||||||
},
|
|
||||||
allowExtensions = arrayOf("jpg", "jpeg", "png", "webp")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Share
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.help.config.SavedTheme
|
import io.legado.app.help.config.SavedTheme
|
||||||
import io.legado.app.help.config.ThemeImportExport
|
import io.legado.app.help.config.ThemeImportExport
|
||||||
|
import io.legado.app.help.config.ThemePackageManager
|
||||||
import io.legado.app.ui.theme.adaptiveContentPadding
|
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||||
import io.legado.app.ui.widget.components.AppScaffold
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
import io.legado.app.ui.widget.components.AppTextField
|
import io.legado.app.ui.widget.components.AppTextField
|
||||||
@@ -56,11 +58,14 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
|||||||
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
||||||
import io.legado.app.utils.restart
|
import io.legado.app.utils.restart
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ThemeManageScreen(
|
fun ThemeManageScreen(
|
||||||
onBackClick: () -> Unit
|
onBackClick: () -> Unit,
|
||||||
|
viewModel: ThemeManageViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -76,34 +81,53 @@ fun ThemeManageScreen(
|
|||||||
val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
|
val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/json")
|
ActivityResultContracts.CreateDocument("application/zip")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
val target = exportTarget
|
val target = exportTarget
|
||||||
if (target != null) {
|
exportTarget = null
|
||||||
exportTarget = null
|
viewModel.onIntent(
|
||||||
if (ThemeImportExport.exportSavedThemeToFile(context, target, it)) {
|
ThemeManageIntent.ExportPackage(
|
||||||
context.toastOnUi(R.string.theme_manage_export_success)
|
uri = it.toString(),
|
||||||
} else {
|
themeName = target?.name,
|
||||||
context.toastOnUi(R.string.theme_manage_export_failed)
|
themeData = target?.data,
|
||||||
}
|
)
|
||||||
} else if (ThemeImportExport.exportToFile(context, it)) {
|
)
|
||||||
context.toastOnUi(R.string.theme_manage_export_success)
|
|
||||||
} else {
|
|
||||||
context.toastOnUi(R.string.theme_manage_export_failed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val importLauncher = rememberLauncherForActivityResult(
|
val importPackageLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenDocument()
|
ActivityResultContracts.OpenDocument()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
if (ThemeImportExport.importFromUri(context, it)) {
|
viewModel.onIntent(ThemeManageIntent.ImportPackage(it.toString()))
|
||||||
context.toastOnUi(R.string.theme_manage_import_success)
|
}
|
||||||
showRestartDialog = true
|
}
|
||||||
} else {
|
|
||||||
context.toastOnUi(R.string.theme_manage_import_failed)
|
val importLegacyLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
viewModel.onIntent(ThemeManageIntent.ImportLegacyJson(it.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collectLatest { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is ThemeManageEffect.ShowResult -> {
|
||||||
|
val message = buildString {
|
||||||
|
append(context.getString(effect.messageRes))
|
||||||
|
effect.detail?.takeIf(String::isNotBlank)?.let {
|
||||||
|
append('\n')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.toastOnUi(message)
|
||||||
|
if (effect.restartRequired) {
|
||||||
|
showRestartDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,14 +169,26 @@ fun ThemeManageScreen(
|
|||||||
description = stringResource(R.string.theme_manage_export_current_summary),
|
description = stringResource(R.string.theme_manage_export_current_summary),
|
||||||
onClick = {
|
onClick = {
|
||||||
exportTarget = null
|
exportTarget = null
|
||||||
exportLauncher.launch("legado_theme_${System.currentTimeMillis()}.json")
|
exportLauncher.launch(
|
||||||
|
"materado_theme_${System.currentTimeMillis()}." +
|
||||||
|
ThemePackageManager.FILE_EXTENSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ClickableSettingItem(
|
||||||
|
title = stringResource(R.string.theme_manage_import_package),
|
||||||
|
description = stringResource(R.string.theme_manage_import_package_summary),
|
||||||
|
onClick = {
|
||||||
|
importPackageLauncher.launch(
|
||||||
|
arrayOf("application/zip", "application/octet-stream")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.theme_manage_import_config),
|
title = stringResource(R.string.theme_manage_import_config),
|
||||||
description = stringResource(R.string.theme_manage_import_config_summary),
|
description = stringResource(R.string.theme_manage_import_config_summary),
|
||||||
onClick = {
|
onClick = {
|
||||||
importLauncher.launch(arrayOf("application/json"))
|
importLegacyLauncher.launch(arrayOf("application/json", "text/json"))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -175,7 +211,9 @@ fun ThemeManageScreen(
|
|||||||
onEdit = { editTarget = theme },
|
onEdit = { editTarget = theme },
|
||||||
onExport = {
|
onExport = {
|
||||||
exportTarget = theme
|
exportTarget = theme
|
||||||
exportLauncher.launch("${theme.name}.json")
|
exportLauncher.launch(
|
||||||
|
"${theme.name}.${ThemePackageManager.FILE_EXTENSION}"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onDelete = { deleteTarget = theme }
|
onDelete = { deleteTarget = theme }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package io.legado.app.ui.config.themeManage
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.help.config.ThemeExportData
|
||||||
|
import io.legado.app.help.config.ThemePackageManager
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ThemeManageViewModel(
|
||||||
|
private val themePackageManager: ThemePackageManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _effects = MutableSharedFlow<ThemeManageEffect>(extraBufferCapacity = 8)
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
|
fun onIntent(intent: ThemeManageIntent) {
|
||||||
|
when (intent) {
|
||||||
|
is ThemeManageIntent.ExportPackage -> exportPackage(intent)
|
||||||
|
is ThemeManageIntent.ImportPackage -> importPackage(intent.uri)
|
||||||
|
is ThemeManageIntent.ImportLegacyJson -> importLegacyJson(intent.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportPackage(intent: ThemeManageIntent.ExportPackage) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = themePackageManager.exportPackage(
|
||||||
|
uri = Uri.parse(intent.uri),
|
||||||
|
themeName = intent.themeName,
|
||||||
|
themeData = intent.themeData,
|
||||||
|
)
|
||||||
|
_effects.emit(
|
||||||
|
if (result.isSuccess) {
|
||||||
|
ThemeManageEffect.ShowResult(R.string.theme_manage_export_success)
|
||||||
|
} else {
|
||||||
|
ThemeManageEffect.ShowResult(
|
||||||
|
messageRes = R.string.theme_manage_export_failed,
|
||||||
|
detail = result.exceptionOrNull()?.localizedMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importPackage(uri: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
emitImportResult(themePackageManager.importPackage(Uri.parse(uri)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importLegacyJson(uri: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
emitImportResult(themePackageManager.importLegacyJson(Uri.parse(uri)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun emitImportResult(result: Result<Unit>) {
|
||||||
|
_effects.emit(
|
||||||
|
if (result.isSuccess) {
|
||||||
|
ThemeManageEffect.ShowResult(
|
||||||
|
messageRes = R.string.theme_manage_import_success,
|
||||||
|
restartRequired = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ThemeManageEffect.ShowResult(
|
||||||
|
messageRes = R.string.theme_manage_import_failed,
|
||||||
|
detail = result.exceptionOrNull()?.localizedMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ThemeManageIntent {
|
||||||
|
data class ExportPackage(
|
||||||
|
val uri: String,
|
||||||
|
val themeName: String? = null,
|
||||||
|
val themeData: ThemeExportData? = null,
|
||||||
|
) : ThemeManageIntent
|
||||||
|
|
||||||
|
data class ImportPackage(val uri: String) : ThemeManageIntent
|
||||||
|
data class ImportLegacyJson(val uri: String) : ThemeManageIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ThemeManageEffect {
|
||||||
|
data class ShowResult(
|
||||||
|
@param:StringRes val messageRes: Int,
|
||||||
|
val detail: String? = null,
|
||||||
|
val restartRequired: Boolean = false,
|
||||||
|
) : ThemeManageEffect
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import io.legado.app.ui.config.ai.AiConfigRouteScreen
|
|||||||
import io.legado.app.ui.config.ai.AiModelEditRouteScreen
|
import io.legado.app.ui.config.ai.AiModelEditRouteScreen
|
||||||
import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
|
import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
|
||||||
import io.legado.app.ui.config.backupConfig.BackupConfigScreen
|
import io.legado.app.ui.config.backupConfig.BackupConfigScreen
|
||||||
|
import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen
|
||||||
import io.legado.app.ui.config.coverConfig.CoverConfigScreen
|
import io.legado.app.ui.config.coverConfig.CoverConfigScreen
|
||||||
import io.legado.app.ui.config.customTheme.CustomThemeScreen
|
import io.legado.app.ui.config.customTheme.CustomThemeScreen
|
||||||
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
|
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
|
||||||
@@ -207,7 +208,16 @@ fun MainActivity.mainEntryProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry<MainRouteSettingsCover> {
|
entry<MainRouteSettingsCover> {
|
||||||
CoverConfigScreen(onBackClick = { onNavigateBack() })
|
CoverConfigScreen(
|
||||||
|
onBackClick = { onNavigateBack() },
|
||||||
|
onNavigateToCoverAlbums = {
|
||||||
|
backStack.add(MainRouteSettingsCoverAlbums)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<MainRouteSettingsCoverAlbums> {
|
||||||
|
CoverAlbumManageRouteScreen(onBackClick = { onNavigateBack() })
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<MainRouteSettingsTheme> {
|
entry<MainRouteSettingsTheme> {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ data object MainRouteSettingsRead : MainRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object MainRouteSettingsCover : MainRoute
|
data object MainRouteSettingsCover : MainRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object MainRouteSettingsCoverAlbums : MainRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object MainRouteSettingsTheme : MainRoute
|
data object MainRouteSettingsTheme : MainRoute
|
||||||
|
|
||||||
@@ -133,6 +136,7 @@ object MainRouteConst {
|
|||||||
const val ROUTE_SETTINGS_OTHER = "settings/other"
|
const val ROUTE_SETTINGS_OTHER = "settings/other"
|
||||||
const val ROUTE_SETTINGS_READ = "settings/read"
|
const val ROUTE_SETTINGS_READ = "settings/read"
|
||||||
const val ROUTE_SETTINGS_COVER = "settings/cover"
|
const val ROUTE_SETTINGS_COVER = "settings/cover"
|
||||||
|
const val ROUTE_SETTINGS_COVER_ALBUMS = "settings/cover/albums"
|
||||||
const val ROUTE_SETTINGS_THEME = "settings/theme"
|
const val ROUTE_SETTINGS_THEME = "settings/theme"
|
||||||
const val ROUTE_SETTINGS_BACKUP = "settings/backup"
|
const val ROUTE_SETTINGS_BACKUP = "settings/backup"
|
||||||
const val ROUTE_SETTINGS_AI = "settings/ai"
|
const val ROUTE_SETTINGS_AI = "settings/ai"
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ fun BookCoverImage(
|
|||||||
|
|
||||||
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
||||||
val finalPath = if (useDefault) null else path
|
val finalPath = if (useDefault) null else path
|
||||||
|
val defaultCoverPaths =
|
||||||
|
if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
||||||
|
|
||||||
val randomPath = remember(name, author, path, isNight) {
|
val randomPath = remember(name, author, path, isNight, defaultCoverPaths) {
|
||||||
BookCoverModel.getRandomDefaultPath(
|
BookCoverModel.getRandomDefaultPath(
|
||||||
seed = name ?: author ?: path ?: "",
|
seed = name ?: author ?: path ?: "",
|
||||||
isNight = isNight
|
isNight = isNight
|
||||||
@@ -164,8 +166,10 @@ fun CoilBookCover(
|
|||||||
|
|
||||||
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
||||||
val finalPath = if (useDefault) null else path
|
val finalPath = if (useDefault) null else path
|
||||||
|
val defaultCoverPaths =
|
||||||
|
if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
||||||
|
|
||||||
val randomPath = remember(name, author, path, isNight) {
|
val randomPath = remember(name, author, path, isNight, defaultCoverPaths) {
|
||||||
BookCoverModel.getRandomDefaultPath(
|
BookCoverModel.getRandomDefaultPath(
|
||||||
seed = name ?: author ?: path ?: "",
|
seed = name ?: author ?: path ?: "",
|
||||||
isNight = isNight
|
isNight = isNight
|
||||||
|
|||||||
@@ -1439,9 +1439,11 @@
|
|||||||
<string name="theme_manage_save_current">保存当前设置</string>
|
<string name="theme_manage_save_current">保存当前设置</string>
|
||||||
<string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
|
<string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
|
||||||
<string name="theme_manage_export_current">导出当前主题</string>
|
<string name="theme_manage_export_current">导出当前主题</string>
|
||||||
<string name="theme_manage_export_current_summary">将当前主题配置导出为 JSON 文件</string>
|
<string name="theme_manage_export_current_summary">将配置、图片、字体和当前图集导出为主题包</string>
|
||||||
<string name="theme_manage_import_config">导入主题配置</string>
|
<string name="theme_manage_import_package">导入主题包</string>
|
||||||
<string name="theme_manage_import_config_summary">从 JSON 文件导入主题配置</string>
|
<string name="theme_manage_import_package_summary">导入包含资源和封面图集的主题包</string>
|
||||||
|
<string name="theme_manage_import_config">导入旧版主题配置</string>
|
||||||
|
<string name="theme_manage_import_config_summary">从旧版 JSON 文件导入主题配置</string>
|
||||||
<string name="theme_manage_saved_themes">已保存的主题</string>
|
<string name="theme_manage_saved_themes">已保存的主题</string>
|
||||||
<string name="theme_manage_save_theme">保存主题</string>
|
<string name="theme_manage_save_theme">保存主题</string>
|
||||||
<string name="theme_manage_save">保存</string>
|
<string name="theme_manage_save">保存</string>
|
||||||
@@ -2217,4 +2219,20 @@
|
|||||||
<string name="switch_to_dark_mode_tip">当前环境光较暗,是否切换至深色模式?</string>
|
<string name="switch_to_dark_mode_tip">当前环境光较暗,是否切换至深色模式?</string>
|
||||||
<string name="switch_to_light_mode_tip">当前环境光较亮,是否切换至浅色模式?</string>
|
<string name="switch_to_light_mode_tip">当前环境光较亮,是否切换至浅色模式?</string>
|
||||||
<string name="switch_action">切换</string>
|
<string name="switch_action">切换</string>
|
||||||
|
<string name="cover_albums">封面图集</string>
|
||||||
|
<string name="manage_cover_albums">管理封面图集</string>
|
||||||
|
<string name="select_cover_album">选择封面图集</string>
|
||||||
|
<string name="cover_album_none">应用内置封面</string>
|
||||||
|
<string name="cover_album_name">图集名称</string>
|
||||||
|
<string name="cover_album_empty">暂无封面图集</string>
|
||||||
|
<string name="cover_album_create">新建封面图集</string>
|
||||||
|
<string name="cover_album_rename">重命名封面图集</string>
|
||||||
|
<string name="cover_album_delete">删除封面图集</string>
|
||||||
|
<string name="cover_album_delete_confirmation">删除“%1$s”及其中的全部图片?</string>
|
||||||
|
<string name="cover_album_add_images">添加图片</string>
|
||||||
|
<string name="default_light_cover_album">默认日间封面</string>
|
||||||
|
<string name="default_dark_cover_album">默认夜间封面</string>
|
||||||
|
<string name="cover_album_imported_name">%1$s(导入)</string>
|
||||||
|
<string name="cover_album_day_night_count">日间 %1$d · 夜间 %2$d</string>
|
||||||
|
<string name="cover_album_selected">当前使用</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2105,11 +2105,13 @@
|
|||||||
<string name="theme_manage_delete_theme">刪除主題</string>
|
<string name="theme_manage_delete_theme">刪除主題</string>
|
||||||
<string name="theme_manage_edit_theme">編輯主題</string>
|
<string name="theme_manage_edit_theme">編輯主題</string>
|
||||||
<string name="theme_manage_export_current">匯出目前主題</string>
|
<string name="theme_manage_export_current">匯出目前主題</string>
|
||||||
<string name="theme_manage_export_current_summary">將目前主題設定匯出為 JSON 檔案</string>
|
<string name="theme_manage_export_current_summary">將設定、圖片、字體和目前圖集匯出為主題包</string>
|
||||||
<string name="theme_manage_export_failed">主題匯出失敗</string>
|
<string name="theme_manage_export_failed">主題匯出失敗</string>
|
||||||
<string name="theme_manage_export_success">主題已匯出</string>
|
<string name="theme_manage_export_success">主題已匯出</string>
|
||||||
<string name="theme_manage_import_config">匯入主題檔案</string>
|
<string name="theme_manage_import_package">匯入主題包</string>
|
||||||
<string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
|
<string name="theme_manage_import_package_summary">匯入包含資源和封面圖集的主題包</string>
|
||||||
|
<string name="theme_manage_import_config">匯入舊版主題設定</string>
|
||||||
|
<string name="theme_manage_import_config_summary">從舊版 JSON 檔案匯入主題設定</string>
|
||||||
<string name="theme_manage_import_failed">主題匯入失敗</string>
|
<string name="theme_manage_import_failed">主題匯入失敗</string>
|
||||||
<string name="theme_manage_import_success">主題已匯入。部分更改需要重啟。</string>
|
<string name="theme_manage_import_success">主題已匯入。部分更改需要重啟。</string>
|
||||||
<string name="theme_manage_label_container_color">標籤容器色</string>
|
<string name="theme_manage_label_container_color">標籤容器色</string>
|
||||||
@@ -2209,4 +2211,20 @@
|
|||||||
<string name="switch_to_dark_mode_tip">當前環境光較暗,是否切換至深色模式?</string>
|
<string name="switch_to_dark_mode_tip">當前環境光較暗,是否切換至深色模式?</string>
|
||||||
<string name="switch_to_light_mode_tip">當前環境光較亮,是否切換至淺色模式?</string>
|
<string name="switch_to_light_mode_tip">當前環境光較亮,是否切換至淺色模式?</string>
|
||||||
<string name="switch_action">切換</string>
|
<string name="switch_action">切換</string>
|
||||||
|
<string name="cover_albums">封面圖集</string>
|
||||||
|
<string name="manage_cover_albums">管理封面圖集</string>
|
||||||
|
<string name="select_cover_album">選擇封面圖集</string>
|
||||||
|
<string name="cover_album_none">應用程式內置封面</string>
|
||||||
|
<string name="cover_album_name">圖集名稱</string>
|
||||||
|
<string name="cover_album_empty">暫無封面圖集</string>
|
||||||
|
<string name="cover_album_create">新增封面圖集</string>
|
||||||
|
<string name="cover_album_rename">重新命名封面圖集</string>
|
||||||
|
<string name="cover_album_delete">刪除封面圖集</string>
|
||||||
|
<string name="cover_album_delete_confirmation">刪除「%1$s」及其中的全部圖片?</string>
|
||||||
|
<string name="cover_album_add_images">加入圖片</string>
|
||||||
|
<string name="default_light_cover_album">預設日間封面</string>
|
||||||
|
<string name="default_dark_cover_album">預設夜間封面</string>
|
||||||
|
<string name="cover_album_imported_name">%1$s(匯入)</string>
|
||||||
|
<string name="cover_album_day_night_count">日間 %1$d · 夜間 %2$d</string>
|
||||||
|
<string name="cover_album_selected">目前使用</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1586,9 +1586,11 @@
|
|||||||
<string name="theme_manage_save_current">儲存目前主題</string>
|
<string name="theme_manage_save_current">儲存目前主題</string>
|
||||||
<string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
|
<string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
|
||||||
<string name="theme_manage_export_current">匯出目前主題</string>
|
<string name="theme_manage_export_current">匯出目前主題</string>
|
||||||
<string name="theme_manage_export_current_summary">將目前主題設定匯出為 JSON 檔案</string>
|
<string name="theme_manage_export_current_summary">將設定、圖片、字型和目前圖集匯出為主題包</string>
|
||||||
<string name="theme_manage_import_config">匯入主題檔案</string>
|
<string name="theme_manage_import_package">匯入主題包</string>
|
||||||
<string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
|
<string name="theme_manage_import_package_summary">匯入包含資源和封面圖集的主題包</string>
|
||||||
|
<string name="theme_manage_import_config">匯入舊版主題設定</string>
|
||||||
|
<string name="theme_manage_import_config_summary">從舊版 JSON 檔案匯入主題設定</string>
|
||||||
<string name="theme_manage_saved_themes">已儲存的主題</string>
|
<string name="theme_manage_saved_themes">已儲存的主題</string>
|
||||||
<string name="theme_manage_save_theme">儲存主題</string>
|
<string name="theme_manage_save_theme">儲存主題</string>
|
||||||
<string name="theme_manage_save">儲存</string>
|
<string name="theme_manage_save">儲存</string>
|
||||||
@@ -2214,4 +2216,20 @@
|
|||||||
<string name="switch_to_dark_mode_tip">目前環境光較暗,是否切換至深色模式?</string>
|
<string name="switch_to_dark_mode_tip">目前環境光較暗,是否切換至深色模式?</string>
|
||||||
<string name="switch_to_light_mode_tip">目前環境光較亮,是否切換至淺色模式?</string>
|
<string name="switch_to_light_mode_tip">目前環境光較亮,是否切換至淺色模式?</string>
|
||||||
<string name="switch_action">切換</string>
|
<string name="switch_action">切換</string>
|
||||||
|
<string name="cover_albums">封面圖集</string>
|
||||||
|
<string name="manage_cover_albums">管理封面圖集</string>
|
||||||
|
<string name="select_cover_album">選擇封面圖集</string>
|
||||||
|
<string name="cover_album_none">應用程式內建封面</string>
|
||||||
|
<string name="cover_album_name">圖集名稱</string>
|
||||||
|
<string name="cover_album_empty">暫無封面圖集</string>
|
||||||
|
<string name="cover_album_create">新增封面圖集</string>
|
||||||
|
<string name="cover_album_rename">重新命名封面圖集</string>
|
||||||
|
<string name="cover_album_delete">刪除封面圖集</string>
|
||||||
|
<string name="cover_album_delete_confirmation">刪除「%1$s」及其中的全部圖片?</string>
|
||||||
|
<string name="cover_album_add_images">加入圖片</string>
|
||||||
|
<string name="default_light_cover_album">預設日間封面</string>
|
||||||
|
<string name="default_dark_cover_album">預設夜間封面</string>
|
||||||
|
<string name="cover_album_imported_name">%1$s(匯入)</string>
|
||||||
|
<string name="cover_album_day_night_count">日間 %1$d · 夜間 %2$d</string>
|
||||||
|
<string name="cover_album_selected">目前使用</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1474,9 +1474,11 @@
|
|||||||
<string name="theme_manage_save_current">Save Current Theme</string>
|
<string name="theme_manage_save_current">Save Current Theme</string>
|
||||||
<string name="theme_manage_save_current_summary">Save the current appearance settings as a theme</string>
|
<string name="theme_manage_save_current_summary">Save the current appearance settings as a theme</string>
|
||||||
<string name="theme_manage_export_current">Export Current Theme</string>
|
<string name="theme_manage_export_current">Export Current Theme</string>
|
||||||
<string name="theme_manage_export_current_summary">Export the current theme settings as a JSON file</string>
|
<string name="theme_manage_export_current_summary">Export settings, assets, fonts, and active cover albums as a theme package</string>
|
||||||
<string name="theme_manage_import_config">Import Theme File</string>
|
<string name="theme_manage_import_package">Import theme package</string>
|
||||||
<string name="theme_manage_import_config_summary">Import theme settings from a JSON file</string>
|
<string name="theme_manage_import_package_summary">Import a theme package with its assets and cover albums</string>
|
||||||
|
<string name="theme_manage_import_config">Import legacy theme configuration</string>
|
||||||
|
<string name="theme_manage_import_config_summary">Import a legacy JSON theme configuration</string>
|
||||||
<string name="theme_manage_saved_themes">Saved Themes</string>
|
<string name="theme_manage_saved_themes">Saved Themes</string>
|
||||||
<string name="theme_manage_save_theme">Save Theme</string>
|
<string name="theme_manage_save_theme">Save Theme</string>
|
||||||
<string name="theme_manage_save">Save</string>
|
<string name="theme_manage_save">Save</string>
|
||||||
@@ -2222,4 +2224,20 @@
|
|||||||
<string name="switch_to_dark_mode_tip">Ambient light is low. Switch to dark mode?</string>
|
<string name="switch_to_dark_mode_tip">Ambient light is low. Switch to dark mode?</string>
|
||||||
<string name="switch_to_light_mode_tip">Ambient light is bright. Switch to light mode?</string>
|
<string name="switch_to_light_mode_tip">Ambient light is bright. Switch to light mode?</string>
|
||||||
<string name="switch_action">Switch</string>
|
<string name="switch_action">Switch</string>
|
||||||
|
<string name="cover_albums">Cover albums</string>
|
||||||
|
<string name="manage_cover_albums">Manage cover albums</string>
|
||||||
|
<string name="select_cover_album">Select cover album</string>
|
||||||
|
<string name="cover_album_none">Built-in default cover</string>
|
||||||
|
<string name="cover_album_name">Album name</string>
|
||||||
|
<string name="cover_album_empty">No cover albums</string>
|
||||||
|
<string name="cover_album_create">Create cover album</string>
|
||||||
|
<string name="cover_album_rename">Rename cover album</string>
|
||||||
|
<string name="cover_album_delete">Delete cover album</string>
|
||||||
|
<string name="cover_album_delete_confirmation">Delete “%1$s” and all images in it?</string>
|
||||||
|
<string name="cover_album_add_images">Add images</string>
|
||||||
|
<string name="default_light_cover_album">Default light cover</string>
|
||||||
|
<string name="default_dark_cover_album">Default dark cover</string>
|
||||||
|
<string name="cover_album_imported_name">%1$s (Imported)</string>
|
||||||
|
<string name="cover_album_day_night_count">Light %1$d · Dark %2$d</string>
|
||||||
|
<string name="cover_album_selected">Currently selected</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user