fix: 支持封面图集与 ZIP 主题包
This commit is contained in:
@@ -27,6 +27,10 @@ object LocalPreferencesKeys {
|
||||
val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
|
||||
val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
|
||||
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
|
||||
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.BookshelfRepository
|
||||
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.DictRuleRepository
|
||||
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.BookSearchGateway
|
||||
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.DictionaryGateway
|
||||
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.ChangeSourceSearchUseCase
|
||||
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.ExploreBooksUseCase
|
||||
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.help.coil.CoverFetcher
|
||||
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.okHttpClientManga
|
||||
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.AiProviderEditViewModel
|
||||
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.coverConfig.CoverConfigViewModel
|
||||
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
|
||||
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
|
||||
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
||||
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.rule.DictRuleViewModel
|
||||
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
|
||||
@@ -198,6 +204,7 @@ val appModule = module {
|
||||
singleOf(::CacheBookChaptersUseCase)
|
||||
singleOf(::ChangeBookSourceUseCase)
|
||||
singleOf(::ClearBookCacheUseCase)
|
||||
singleOf(::CoverAlbumUseCase)
|
||||
singleOf(::DeleteBooksUseCase)
|
||||
singleOf(::GetReadingProgressUseCase)
|
||||
single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
|
||||
@@ -214,6 +221,7 @@ val appModule = module {
|
||||
singleOf(::ShrinkDatabaseUseCase)
|
||||
singleOf(::WebDavBackupUseCase)
|
||||
singleOf(::BookshelfManageScreenConfig)
|
||||
singleOf(::ThemePackageManager)
|
||||
|
||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
|
||||
@@ -227,6 +235,7 @@ val appModule = module {
|
||||
single<BackupRestoreGateway> { BackupRestoreRepository() }
|
||||
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
||||
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
||||
single<CoverAlbumGateway> { CoverAlbumRepository(get(), get()) }
|
||||
single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
|
||||
single<LocalBookGateway> { LocalBookRepository(get()) }
|
||||
single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
|
||||
@@ -293,8 +302,10 @@ val appModule = module {
|
||||
viewModelOf(::OtherConfigViewModel)
|
||||
viewModelOf(::ReadConfigViewModel)
|
||||
viewModelOf(::CoverConfigViewModel)
|
||||
viewModelOf(::CoverAlbumManageViewModel)
|
||||
viewModelOf(::DownloadCacheConfigViewModel)
|
||||
viewModelOf(::ThemeConfigViewModel)
|
||||
viewModelOf(::ThemeManageViewModel)
|
||||
viewModelOf(::BackupConfigViewModel)
|
||||
viewModelOf(::AiConfigViewModel)
|
||||
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 androidx.annotation.Keep
|
||||
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.themeConfig.ThemeConfig
|
||||
import io.legado.app.utils.EncoderUtils
|
||||
@@ -41,7 +44,8 @@ object ThemeImportExport {
|
||||
if (file.isFile && file.extension == "json") {
|
||||
kotlin.runCatching {
|
||||
val json = file.readText()
|
||||
val data = EXPORT_GSON.fromJson(json, ThemeExportData::class.java)
|
||||
val data = parseThemeData(json)
|
||||
?: error("不支持的主题配置格式")
|
||||
val name = file.nameWithoutExtension
|
||||
_savedThemes.add(SavedTheme(name = name, data = data))
|
||||
}
|
||||
@@ -121,7 +125,7 @@ object ThemeImportExport {
|
||||
/**
|
||||
* 从当前配置创建导出数据
|
||||
*/
|
||||
fun exportFromCurrent(): ThemeExportData {
|
||||
fun exportFromCurrent(includeEmbeddedAssets: Boolean = true): ThemeExportData {
|
||||
return ThemeExportData(
|
||||
// 基础主题设置
|
||||
appTheme = ThemeConfig.appTheme,
|
||||
@@ -229,7 +233,7 @@ object ThemeImportExport {
|
||||
coverShowNameN = CoverConfig.coverShowNameN,
|
||||
coverShowAuthorN = CoverConfig.coverShowAuthorN,
|
||||
coverInfoOrientation = CoverConfig.coverInfoOrientation,
|
||||
assets = exportAssets()
|
||||
assets = if (includeEmbeddedAssets) exportAssets() else null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -417,6 +421,7 @@ object ThemeImportExport {
|
||||
val coverPaths = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
assets.forEach { (key, base64) ->
|
||||
if (base64.isBlank()) return@forEach
|
||||
try {
|
||||
val bytes = EncoderUtils.base64DecodeToByteArray(base64)
|
||||
val destFile = when {
|
||||
@@ -499,7 +504,7 @@ object ThemeImportExport {
|
||||
*/
|
||||
fun importFromJson(json: String): Boolean {
|
||||
return try {
|
||||
val data = GSON.fromJson(json, ThemeExportData::class.java)
|
||||
val data = parseThemeData(json) ?: return false
|
||||
applyToThemeConfig(data)
|
||||
true
|
||||
} 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导入主题
|
||||
*/
|
||||
|
||||
@@ -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.Companion.setCoroutineContext
|
||||
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.GSON
|
||||
import io.legado.app.utils.fromJsonObject
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import kotlin.random.Random
|
||||
|
||||
@Keep
|
||||
object BookCover {
|
||||
object BookCover : KoinComponent {
|
||||
|
||||
private const val coverRuleConfigKey = "legadoCoverRuleConfig"
|
||||
const val configFileName = "coverRule.json"
|
||||
private val coverAlbumUseCase: CoverAlbumUseCase by inject()
|
||||
|
||||
val defaultDrawable: Drawable
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
get() {
|
||||
val isNightTheme = AppConfig.isNightTheme
|
||||
val pathStr = if (isNightTheme) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
||||
val paths = pathStr.split(",").filter { it.isNotBlank() }
|
||||
val paths = coverAlbumUseCase.selectedImagePaths(isNightTheme)
|
||||
|
||||
if (paths.isNullOrEmpty()) {
|
||||
if (paths.isEmpty()) {
|
||||
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
||||
}
|
||||
|
||||
@@ -67,9 +70,8 @@ object BookCover {
|
||||
seed: Any? = null,
|
||||
isNight: Boolean = AppConfig.isNightTheme
|
||||
): String? {
|
||||
val pathStr = if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
|
||||
val paths = pathStr.split(",").filter { it.isNotBlank() }
|
||||
if (paths.isNullOrEmpty()) return null
|
||||
val paths = coverAlbumUseCase.selectedImagePaths(isNight)
|
||||
if (paths.isEmpty()) return null
|
||||
val random = if (seed != null) Random(seed.hashCode()) else Random
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||
import io.legado.app.ui.widget.components.AppScaffold
|
||||
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
||||
@@ -38,11 +38,15 @@ import org.koin.androidx.compose.koinViewModel
|
||||
@Composable
|
||||
fun CoverConfigScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToCoverAlbums: () -> Unit,
|
||||
viewModel: CoverConfigViewModel = koinViewModel()
|
||||
) {
|
||||
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 manageKey by remember { mutableStateOf<String?>(null) }
|
||||
var showAlbumSelect by remember { mutableStateOf(false) }
|
||||
var showColorPickerByField by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AppScaffold(
|
||||
@@ -86,6 +90,20 @@ fun CoverConfigScreen(
|
||||
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(
|
||||
title = stringResource(R.string.cover_show_shadow),
|
||||
checked = CoverConfig.coverShowShadow,
|
||||
@@ -130,35 +148,24 @@ fun CoverConfigScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
|
||||
DropdownListSettingItem(
|
||||
title = stringResource(R.string.network_book_badge_setting),
|
||||
selectedValue = CoverConfig.exploreFilterState.toString(),
|
||||
displayEntries = arrayOf(
|
||||
stringResource(R.string.filter_show_all),
|
||||
stringResource(R.string.filter_hide_in_shelf),
|
||||
stringResource(R.string.filter_hide_same_name_author),
|
||||
stringResource(R.string.filter_show_not_in_shelf_only)
|
||||
),
|
||||
entryValues = arrayOf("0", "1", "2", "3"),
|
||||
onValueChange = {
|
||||
CoverConfig.exploreFilterState = it.toInt()
|
||||
}
|
||||
)
|
||||
}
|
||||
SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
|
||||
DropdownListSettingItem(
|
||||
title = stringResource(R.string.network_book_badge_setting),
|
||||
selectedValue = CoverConfig.exploreFilterState.toString(),
|
||||
displayEntries = arrayOf(
|
||||
stringResource(R.string.filter_show_all),
|
||||
stringResource(R.string.filter_hide_in_shelf),
|
||||
stringResource(R.string.filter_hide_same_name_author),
|
||||
stringResource(R.string.filter_show_not_in_shelf_only)
|
||||
),
|
||||
entryValues = arrayOf("0", "1", "2", "3"),
|
||||
onValueChange = {
|
||||
CoverConfig.exploreFilterState = it.toInt()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
title = stringResource(R.string.text_color),
|
||||
option = "#${Integer.toHexString(CoverConfig.coverTextColor).uppercase()}",
|
||||
@@ -205,19 +212,7 @@ fun CoverConfigScreen(
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
|
||||
SplicedColumnGroup(title = stringResource(R.string.night)) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.text_color),
|
||||
option = "#${Integer.toHexString(CoverConfig.coverTextColorN).uppercase()}",
|
||||
@@ -272,12 +267,16 @@ fun CoverConfigScreen(
|
||||
onDismissRequest = { showCoverRuleSheet = false }
|
||||
)
|
||||
|
||||
manageKey?.let { key ->
|
||||
CoverManageSheet(
|
||||
if (showAlbumSelect) {
|
||||
CoverAlbumSelectSheet(
|
||||
show = true,
|
||||
preferenceKey = key,
|
||||
onDismissRequest = { manageKey = null },
|
||||
viewModel = viewModel
|
||||
state = albumState,
|
||||
onSelect = viewModel::selectAlbum,
|
||||
onManage = {
|
||||
showAlbumSelect = false
|
||||
onNavigateToCoverAlbums()
|
||||
},
|
||||
onDismissRequest = { showAlbumSelect = false },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +1,36 @@
|
||||
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 io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
import io.legado.app.lib.permission.Permissions
|
||||
import io.legado.app.lib.permission.PermissionsCompat
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.FileUtils
|
||||
import io.legado.app.utils.MD5Utils
|
||||
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
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.domain.usecase.CoverAlbumUseCase
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CoverConfigViewModel : ViewModel() {
|
||||
class CoverConfigViewModel(
|
||||
private val coverAlbumUseCase: CoverAlbumUseCase,
|
||||
) : ViewModel() {
|
||||
|
||||
fun addCoverFromUri(preferenceKey: String, uris: List<Uri>) {
|
||||
uris.forEach { uri ->
|
||||
readUri(appCtx, uri) { fileDoc, _ ->
|
||||
kotlin.runCatching {
|
||||
var file = appCtx.externalFiles
|
||||
val suffix = fileDoc.name.substringAfterLast(".")
|
||||
val inputStreamForMd5 = appCtx.contentResolver.openInputStream(uri)
|
||||
?: throw NoStackTraceException("无法打开输入流")
|
||||
val fileName = MD5Utils.md5Encode(inputStreamForMd5) + ".$suffix"
|
||||
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
|
||||
FileOutputStream(file).use {
|
||||
appCtx.contentResolver.openInputStream(uri)?.use { input ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val albumState = combine(
|
||||
coverAlbumUseCase.albums,
|
||||
coverAlbumUseCase.selection,
|
||||
) { albums, selection ->
|
||||
CoverAlbumSelectionUiState(
|
||||
albums = albums.map { it.toUi() }.toImmutableList(),
|
||||
selectedAlbumId = selection.albumId,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = CoverAlbumSelectionUiState(),
|
||||
)
|
||||
|
||||
private fun readUri(
|
||||
context: Context,
|
||||
uri: Uri?,
|
||||
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
|
||||
fun selectAlbum(albumId: String?) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
coverAlbumUseCase.selectAlbum(albumId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,3 +54,20 @@ class CoverConfigViewModel : ViewModel() {
|
||||
// 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.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.R
|
||||
import io.legado.app.help.config.SavedTheme
|
||||
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.widget.components.AppScaffold
|
||||
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.utils.restart
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThemeManageScreen(
|
||||
onBackClick: () -> Unit
|
||||
onBackClick: () -> Unit,
|
||||
viewModel: ThemeManageViewModel = koinViewModel(),
|
||||
) {
|
||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||
val context = LocalContext.current
|
||||
@@ -76,34 +81,53 @@ fun ThemeManageScreen(
|
||||
val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
ActivityResultContracts.CreateDocument("application/zip")
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
val target = exportTarget
|
||||
if (target != null) {
|
||||
exportTarget = null
|
||||
if (ThemeImportExport.exportSavedThemeToFile(context, target, it)) {
|
||||
context.toastOnUi(R.string.theme_manage_export_success)
|
||||
} else {
|
||||
context.toastOnUi(R.string.theme_manage_export_failed)
|
||||
}
|
||||
} else if (ThemeImportExport.exportToFile(context, it)) {
|
||||
context.toastOnUi(R.string.theme_manage_export_success)
|
||||
} else {
|
||||
context.toastOnUi(R.string.theme_manage_export_failed)
|
||||
}
|
||||
exportTarget = null
|
||||
viewModel.onIntent(
|
||||
ThemeManageIntent.ExportPackage(
|
||||
uri = it.toString(),
|
||||
themeName = target?.name,
|
||||
themeData = target?.data,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
val importPackageLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
if (ThemeImportExport.importFromUri(context, it)) {
|
||||
context.toastOnUi(R.string.theme_manage_import_success)
|
||||
showRestartDialog = true
|
||||
} else {
|
||||
context.toastOnUi(R.string.theme_manage_import_failed)
|
||||
viewModel.onIntent(ThemeManageIntent.ImportPackage(it.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
onClick = {
|
||||
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(
|
||||
title = stringResource(R.string.theme_manage_import_config),
|
||||
description = stringResource(R.string.theme_manage_import_config_summary),
|
||||
onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
importLegacyLauncher.launch(arrayOf("application/json", "text/json"))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -175,7 +211,9 @@ fun ThemeManageScreen(
|
||||
onEdit = { editTarget = theme },
|
||||
onExport = {
|
||||
exportTarget = theme
|
||||
exportLauncher.launch("${theme.name}.json")
|
||||
exportLauncher.launch(
|
||||
"${theme.name}.${ThemePackageManager.FILE_EXTENSION}"
|
||||
)
|
||||
},
|
||||
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.AiProviderEditRouteScreen
|
||||
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.customTheme.CustomThemeScreen
|
||||
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
|
||||
@@ -207,7 +208,16 @@ fun MainActivity.mainEntryProvider(
|
||||
}
|
||||
|
||||
entry<MainRouteSettingsCover> {
|
||||
CoverConfigScreen(onBackClick = { onNavigateBack() })
|
||||
CoverConfigScreen(
|
||||
onBackClick = { onNavigateBack() },
|
||||
onNavigateToCoverAlbums = {
|
||||
backStack.add(MainRouteSettingsCoverAlbums)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<MainRouteSettingsCoverAlbums> {
|
||||
CoverAlbumManageRouteScreen(onBackClick = { onNavigateBack() })
|
||||
}
|
||||
|
||||
entry<MainRouteSettingsTheme> {
|
||||
|
||||
@@ -21,6 +21,9 @@ data object MainRouteSettingsRead : MainRoute
|
||||
@Serializable
|
||||
data object MainRouteSettingsCover : MainRoute
|
||||
|
||||
@Serializable
|
||||
data object MainRouteSettingsCoverAlbums : MainRoute
|
||||
|
||||
@Serializable
|
||||
data object MainRouteSettingsTheme : MainRoute
|
||||
|
||||
@@ -133,6 +136,7 @@ object MainRouteConst {
|
||||
const val ROUTE_SETTINGS_OTHER = "settings/other"
|
||||
const val ROUTE_SETTINGS_READ = "settings/read"
|
||||
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_BACKUP = "settings/backup"
|
||||
const val ROUTE_SETTINGS_AI = "settings/ai"
|
||||
|
||||
@@ -74,8 +74,10 @@ fun BookCoverImage(
|
||||
|
||||
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
||||
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(
|
||||
seed = name ?: author ?: path ?: "",
|
||||
isNight = isNight
|
||||
@@ -164,8 +166,10 @@ fun CoilBookCover(
|
||||
|
||||
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
|
||||
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(
|
||||
seed = name ?: author ?: path ?: "",
|
||||
isNight = isNight
|
||||
|
||||
@@ -1439,9 +1439,11 @@
|
||||
<string name="theme_manage_save_current">保存当前设置</string>
|
||||
<string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
|
||||
<string name="theme_manage_export_current">导出当前主题</string>
|
||||
<string name="theme_manage_export_current_summary">将当前主题配置导出为 JSON 文件</string>
|
||||
<string name="theme_manage_import_config">导入主题配置</string>
|
||||
<string name="theme_manage_import_config_summary">从 JSON 文件导入主题配置</string>
|
||||
<string name="theme_manage_export_current_summary">将配置、图片、字体和当前图集导出为主题包</string>
|
||||
<string name="theme_manage_import_package">导入主题包</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_save_theme">保存主题</string>
|
||||
<string name="theme_manage_save">保存</string>
|
||||
@@ -2217,4 +2219,20 @@
|
||||
<string name="switch_to_dark_mode_tip">当前环境光较暗,是否切换至深色模式?</string>
|
||||
<string name="switch_to_light_mode_tip">当前环境光较亮,是否切换至浅色模式?</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>
|
||||
|
||||
@@ -2105,11 +2105,13 @@
|
||||
<string name="theme_manage_delete_theme">刪除主題</string>
|
||||
<string name="theme_manage_edit_theme">編輯主題</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_success">主題已匯出</string>
|
||||
<string name="theme_manage_import_config">匯入主題檔案</string>
|
||||
<string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
|
||||
<string name="theme_manage_import_package">匯入主題包</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_success">主題已匯入。部分更改需要重啟。</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_light_mode_tip">當前環境光較亮,是否切換至淺色模式?</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>
|
||||
|
||||
@@ -1586,9 +1586,11 @@
|
||||
<string name="theme_manage_save_current">儲存目前主題</string>
|
||||
<string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
|
||||
<string name="theme_manage_export_current">匯出目前主題</string>
|
||||
<string name="theme_manage_export_current_summary">將目前主題設定匯出為 JSON 檔案</string>
|
||||
<string name="theme_manage_import_config">匯入主題檔案</string>
|
||||
<string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
|
||||
<string name="theme_manage_export_current_summary">將設定、圖片、字型和目前圖集匯出為主題包</string>
|
||||
<string name="theme_manage_import_package">匯入主題包</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_save_theme">儲存主題</string>
|
||||
<string name="theme_manage_save">儲存</string>
|
||||
@@ -2214,4 +2216,20 @@
|
||||
<string name="switch_to_dark_mode_tip">目前環境光較暗,是否切換至深色模式?</string>
|
||||
<string name="switch_to_light_mode_tip">目前環境光較亮,是否切換至淺色模式?</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>
|
||||
|
||||
@@ -1474,9 +1474,11 @@
|
||||
<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_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_import_config">Import Theme File</string>
|
||||
<string name="theme_manage_import_config_summary">Import theme settings from 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_package">Import theme package</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_save_theme">Save Theme</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_light_mode_tip">Ambient light is bright. Switch to light mode?</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>
|
||||
|
||||
Reference in New Issue
Block a user