Просмотр исходного кода

feat: 更好的主题管理方法

HapeLee 4 месяцев назад
Родитель
Сommit
d62a46864a

+ 6 - 4
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -94,10 +94,10 @@ object ThemeImportExport {
      */
     fun deleteSavedTheme(theme: SavedTheme) {
         val file = File(baseDir, "${theme.name}.json")
-        if (file.exists()) {
-            file.delete()
+        if (file.exists() && !file.delete()) {
+            error("Failed to delete saved theme: ${theme.name}")
         }
-        _savedThemes.remove(theme)
+        _savedThemes.removeAll { it.name == theme.name }
     }
 
     /**
@@ -813,7 +813,9 @@ data class ThemeExportData(
 @Keep
 data class SavedTheme(
     val name: String,
-    val data: ThemeExportData
+    val data: ThemeExportData,
+    val packageRootPath: String? = null,
+    val packageManifest: ThemePackageManifest? = null,
 )
 
 internal data class AppliedThemeAssets(

+ 356 - 20
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -9,6 +9,7 @@ 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.utils.EncoderUtils
 import io.legado.app.utils.GSON
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -19,6 +20,7 @@ import java.io.File
 import java.io.FileOutputStream
 import java.io.InputStream
 import java.io.OutputStream
+import java.net.URLEncoder
 import java.util.UUID
 import java.util.zip.ZipEntry
 import java.util.zip.ZipInputStream
@@ -33,6 +35,7 @@ class ThemePackageManager(
         const val FILE_EXTENSION = "zip"
         private const val FORMAT_VERSION = 1
         private const val MANIFEST_PATH = "manifest.json"
+        private const val SAVED_THEME_DIR = "saved_themes"
         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
@@ -51,8 +54,15 @@ class ThemePackageManager(
         uri: Uri,
         themeName: String? = null,
         themeData: ThemeExportData? = null,
+        savedTheme: SavedTheme? = null,
     ): Result<Unit> = withContext(Dispatchers.IO) {
         runSuspendCatching {
+            val savedRoot = savedTheme?.packageRootPath?.let(::File)
+            if (savedRoot?.isDirectory == true && savedTheme.packageManifest != null) {
+                exportSavedThemeFolder(uri, savedRoot)
+                return@runSuspendCatching
+            }
+
             val rawConfig = themeData ?: ThemeImportExport.exportFromCurrent(
                 includeEmbeddedAssets = false
             )
@@ -165,21 +175,55 @@ class ThemePackageManager(
         }
     }
 
-    fun saveTheme(
+    suspend fun loadSavedThemes(): List<SavedTheme> = withContext(Dispatchers.IO) {
+        savedThemesRoot.mkdirs()
+        val folderThemes = savedThemesRoot
+            .listFiles()
+            .orEmpty()
+            .filter(File::isDirectory)
+            .mapNotNull(::readSavedThemeFolder)
+        ThemeImportExport.reload()
+        val folderThemeNames = folderThemes.mapTo(mutableSetOf()) { it.name }
+        folderThemes + ThemeImportExport.savedThemes.filter { it.name !in folderThemeNames }
+    }
+
+    suspend fun saveTheme(
         name: String,
-        data: ThemeExportData = ThemeImportExport.exportFromCurrent(),
-    ): SavedTheme {
-        val selectedCoverAlbumId = data.selectedCoverAlbumId
+        data: ThemeExportData? = null,
+    ): SavedTheme = withContext(Dispatchers.IO) {
+        val themeData = data ?: ThemeImportExport.exportFromCurrent(includeEmbeddedAssets = false)
+        val selectedCoverAlbumId = themeData.selectedCoverAlbumId
             ?: coverAlbumUseCase.selection.value.albumId
-        return ThemeImportExport.saveCurrentAsTheme(
+        saveThemeFolder(
             name = name,
-            data = data.copy(selectedCoverAlbumId = selectedCoverAlbumId),
+            data = themeData.copy(selectedCoverAlbumId = selectedCoverAlbumId),
         )
     }
 
+    suspend fun deleteSavedTheme(theme: SavedTheme): Result<Unit> = withContext(Dispatchers.IO) {
+        runSuspendCatching {
+            val folder = theme.packageRootPath
+                ?.let(::File)
+                ?.takeIf(File::exists)
+                ?: savedThemeDir(theme.name).takeIf(File::exists)
+            if (folder != null && !folder.deleteRecursively()) {
+                error("Failed to delete saved theme: ${theme.name}")
+            }
+
+            ThemeImportExport.deleteSavedTheme(theme)
+        }
+    }
+
     suspend fun applySavedTheme(theme: SavedTheme): Result<Unit> =
         withContext(Dispatchers.IO) {
             runSuspendCatching {
+                val packageRoot = theme.packageRootPath?.let(::File)
+                val packageManifest = theme.packageManifest
+                if (packageRoot?.isDirectory == true && packageManifest != null) {
+                    applySavedThemeFolder(theme, packageRoot, packageManifest)
+                    return@runSuspendCatching
+                }
+
                 val savedAlbumId = theme.data.selectedCoverAlbumId
                     ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
                 val appliedAssets = ThemeImportExport.applyToThemeConfig(
@@ -206,26 +250,291 @@ class ThemePackageManager(
             }
         }
 
-    private fun exportAssets(
-        zip: ZipOutputStream,
+    private val savedThemesRoot: File
+        get() = File(context.filesDir, SAVED_THEME_DIR)
+
+    private fun readSavedThemeFolder(root: File): SavedTheme? {
+        return runCatching {
+            val manifestFile = resolvePackageFile(root, MANIFEST_PATH)
+            if (!manifestFile.isFile) return null
+            val manifestJson = manifestFile.readText()
+            val manifest = GSON.fromJson(manifestJson, ThemePackageManifest::class.java)
+            require(manifest.formatVersion == FORMAT_VERSION) {
+                "Unsupported saved theme version: ${manifest.formatVersion}"
+            }
+            val localAssets = resolveSavedAssets(root, manifest.assets)
+            val data = 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],
+                assets = null,
+            )
+            SavedTheme(
+                name = manifest.name?.takeIf(String::isNotBlank) ?: root.name,
+                data = data,
+                packageRootPath = root.absolutePath,
+                packageManifest = manifest,
+            )
+        }.getOrNull()
+    }
+
+    private fun saveThemeFolder(
+        name: String,
+        data: ThemeExportData,
+    ): SavedTheme {
+        savedThemesRoot.mkdirs()
+        val targetRoot = savedThemeDir(name)
+        val tempRoot = File(savedThemesRoot, "${targetRoot.name}_${UUID.randomUUID()}.tmp")
+        tempRoot.mkdirs()
+        try {
+            val assetEntries = copyAssetsToFolder(tempRoot, data)
+            val coverData = copyCoverAlbumsToFolder(
+                root = tempRoot,
+                preferredAlbumId = data.selectedCoverAlbumId,
+            )
+            val manifest = ThemePackageManifest(
+                formatVersion = FORMAT_VERSION,
+                name = name,
+                config = data.toSavedConfig(),
+                assets = assetEntries,
+                coverAlbums = coverData.albums,
+                coverSelection = coverData.selection,
+            )
+            writeSavedManifest(tempRoot, manifest)
+            if (targetRoot.exists() && !targetRoot.deleteRecursively()) {
+                error("Failed to replace saved theme: $name")
+            }
+            if (!tempRoot.renameTo(targetRoot)) {
+                tempRoot.copyRecursively(targetRoot, overwrite = true)
+                tempRoot.deleteRecursively()
+            }
+            File(savedThemesRoot, "$name.json").takeIf(File::exists)?.delete()
+            return readSavedThemeFolder(targetRoot) ?: SavedTheme(name = name, data = data)
+        } catch (error: Exception) {
+            tempRoot.deleteRecursively()
+            throw error
+        }
+    }
+
+    private fun exportSavedThemeFolder(
+        uri: Uri,
+        root: File,
+    ) {
+        context.contentResolver.openOutputStream(uri)?.use { output ->
+            ZipOutputStream(BufferedOutputStream(output)).use { zip ->
+                root.walkTopDown()
+                    .filter(File::isFile)
+                    .forEach { file ->
+                        val entryPath = root.toPath()
+                            .relativize(file.toPath())
+                            .toString()
+                            .replace(File.separatorChar, '/')
+                        file.inputStream().use { input ->
+                            zip.writeEntry(entryPath, input)
+                        }
+                    }
+            }
+        } ?: error("Unable to create theme package")
+    }
+
+    private suspend fun applySavedThemeFolder(
+        theme: SavedTheme,
+        root: File,
+        manifest: ThemePackageManifest,
+    ) {
+        val localAssets = resolveSavedAssets(root, manifest.assets)
+        val savedAlbumId = theme.data.selectedCoverAlbumId
+            ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
+        val selectedAlbumId = if (savedAlbumId != null) {
+            coverAlbumUseCase.selectAlbum(savedAlbumId)
+            savedAlbumId
+        } else {
+            importSavedCoverAlbum(root, manifest)
+        }
+        ThemeImportExport.applyToThemeConfig(
+            data = 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 = "",
+                selectedCoverAlbumId = selectedAlbumId,
+                assets = null,
+            ),
+            applyEmbeddedCoverAssets = false,
+        )
+        coverAlbumUseCase.selectAlbum(selectedAlbumId)
+        if (selectedAlbumId != manifest.config.selectedCoverAlbumId) {
+            writeSavedManifest(
+                root = root,
+                manifest = manifest.copy(
+                    config = manifest.config.copy(selectedCoverAlbumId = selectedAlbumId),
+                ),
+            )
+        }
+    }
+
+    private suspend fun importSavedCoverAlbum(
+        root: File,
+        manifest: ThemePackageManifest,
+    ): String? {
+        val albumRef = manifest.coverSelection.albumRef ?: return null
+        val importedIds = mutableListOf<String>()
+        return try {
+            val albumIdMap = importCoverAlbums(
+                root = root,
+                albums = manifest.coverAlbums,
+                importedIds = importedIds,
+            )
+            resolveAlbumRef(albumRef, albumIdMap)
+        } catch (error: CancellationException) {
+            throw error
+        } catch (error: Exception) {
+            importedIds.forEach { id ->
+                runCatching { coverAlbumUseCase.deleteAlbum(id) }
+            }
+            throw error
+        }
+    }
+
+    private fun copyAssetsToFolder(
+        root: File,
         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(
+        assetSources(config).forEach { source ->
+            val path = source.sourcePath?.takeIf(String::isNotBlank)
+            if (path != null) {
+                val extension = sourceExtension(path, source.key)
+                val entryPath = "${source.entryBase}.$extension"
+                openSource(path).use { input ->
+                    input.copyToPackageFile(root, entryPath)
+                }
+                result[source.key] = entryPath
+            }
+        }
+        copyEmbeddedAssetsToFolder(root, config, result)
+        return result
+    }
+
+    private fun copyEmbeddedAssetsToFolder(
+        root: File,
+        config: ThemeExportData,
+        result: MutableMap<String, String>,
+    ) {
+        val sources = mapOf(
+            "bgImageLight" to AssetSource(ASSET_BACKGROUND_LIGHT, null, "assets/background/light"),
+            "bgImageDark" to AssetSource(ASSET_BACKGROUND_DARK, null, "assets/background/dark"),
+            "navIconHome" to AssetSource(ASSET_NAV_HOME, null, "assets/navigation/home"),
+            "navIconBookshelf" to AssetSource(
                 ASSET_NAV_BOOKSHELF,
-                config.navIconBookshelf,
+                null,
                 "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"),
+            "navIconExplore" to AssetSource(ASSET_NAV_EXPLORE, null, "assets/navigation/explore"),
+            "navIconRss" to AssetSource(ASSET_NAV_RSS, null, "assets/navigation/rss"),
+            "navIconMy" to AssetSource(ASSET_NAV_MY, null, "assets/navigation/my"),
+            "appFontPath" to AssetSource(ASSET_FONT, null, "assets/fonts/app"),
         )
-        sources.forEach { source ->
+        config.assets.orEmpty().forEach { (legacyKey, base64) ->
+            val source = sources[legacyKey] ?: return@forEach
+            if (source.key in result || base64.isBlank()) return@forEach
+            val extension = if (source.key == ASSET_FONT) "ttf" else "img"
+            val entryPath = "${source.entryBase}.$extension"
+            val target = resolvePackageFile(root, entryPath)
+            target.parentFile?.mkdirs()
+            FileOutputStream(target).use { output ->
+                output.write(EncoderUtils.base64DecodeToByteArray(base64))
+            }
+            result[source.key] = entryPath
+        }
+    }
+
+    private fun copyCoverAlbumsToFolder(
+        root: File,
+        preferredAlbumId: String?,
+    ): ExportedCoverData {
+        val albumsById = coverAlbumUseCase.albums.value.associateBy { it.id }
+        val selectedAlbumId = preferredAlbumId
+            ?.takeIf(albumsById::containsKey)
+            ?: coverAlbumUseCase.selection.value.albumId
+        val selectedIds = listOfNotNull(selectedAlbumId)
+        val refById = selectedIds.associateWith { "album_0" }
+        val exportedAlbums = selectedIds.mapNotNull { albumId ->
+            val album = albumsById[albumId] ?: return@mapNotNull null
+            val albumRef = refById.getValue(albumId)
+            fun copyImages(
+                group: String,
+                images: List<io.legado.app.domain.model.CoverAlbumImage>,
+            ) = images.mapIndexed { index, image ->
+                val file = File(image.path)
+                require(file.isFile) { "Cover album image does not exist: ${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 ->
+                    input.copyToPackageFile(root, entryPath)
+                }
+                ThemePackageCoverImage(path = entryPath)
+            }
+            ThemePackageCoverAlbum(
+                ref = albumRef,
+                name = album.name,
+                lightImages = copyImages("light", album.lightImages),
+                darkImages = copyImages("dark", album.darkImages),
+            )
+        }
+        return ExportedCoverData(
+            albums = exportedAlbums,
+            selection = ThemePackageCoverSelection(
+                albumRef = selectedAlbumId?.let(refById::get),
+            ),
+        )
+    }
+
+    private fun resolveSavedAssets(
+        root: File,
+        entries: Map<String, String>,
+    ): Map<String, String> {
+        return entries.mapNotNull { (key, entryPath) ->
+            val source = resolvePackageFile(root, entryPath)
+            if (source.isFile) key to source.absolutePath else null
+        }.toMap()
+    }
+
+    private fun savedThemeDir(name: String): File {
+        val encoded = URLEncoder.encode(name, Charsets.UTF_8.name())
+            .ifBlank { "theme" }
+        return File(savedThemesRoot, "theme_$encoded")
+    }
+
+    private fun writeSavedManifest(
+        root: File,
+        manifest: ThemePackageManifest,
+    ) {
+        val manifestFile = resolvePackageFile(root, MANIFEST_PATH)
+        manifestFile.parentFile?.mkdirs()
+        manifestFile.writeText(GSON.toJson(manifest))
+    }
+
+    private fun exportAssets(
+        zip: ZipOutputStream,
+        config: ThemeExportData,
+    ): Map<String, String> {
+        val result = linkedMapOf<String, String>()
+        assetSources(config).forEach { source ->
             val path = source.sourcePath?.takeIf(String::isNotBlank) ?: return@forEach
             val extension = sourceExtension(path, source.key)
             val entryPath = "${source.entryBase}.$extension"
@@ -384,7 +693,7 @@ class ThemePackageManager(
         }
     }
 
-    private fun saveImportedTheme(
+    private suspend fun saveImportedTheme(
         name: String,
         selectedCoverAlbumId: String?,
     ) {
@@ -514,6 +823,25 @@ class ThemePackageManager(
         assets = null,
     )
 
+    private fun ThemeExportData.toSavedConfig() = toPortableConfig().copy(
+        selectedCoverAlbumId = selectedCoverAlbumId,
+    )
+
+    private fun assetSources(config: ThemeExportData) = 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"),
+    )
+
     private fun openSource(path: String): InputStream {
         return if (path.startsWith("content://")) {
             context.contentResolver.openInputStream(Uri.parse(path))
@@ -537,6 +865,14 @@ class ThemePackageManager(
         closeEntry()
     }
 
+    private fun InputStream.copyToPackageFile(root: File, path: String) {
+        val target = resolvePackageFile(root, path)
+        target.parentFile?.mkdirs()
+        FileOutputStream(target).use { output ->
+            use { input -> input.copyTo(output) }
+        }
+    }
+
     private fun InputStream.copyLimitedTo(
         output: OutputStream,
         maxEntryBytes: Long,

+ 5 - 13
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt

@@ -28,10 +28,10 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.shadow
@@ -42,7 +42,6 @@ import androidx.compose.ui.res.stringResource
 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
@@ -69,6 +68,7 @@ fun ThemeManageScreen(
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val context = LocalContext.current
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
 
     var showSaveDialog by remember { mutableStateOf(false) }
     var newThemeName by remember { mutableStateOf("") }
@@ -77,8 +77,7 @@ fun ThemeManageScreen(
     var exportTarget by remember { mutableStateOf<SavedTheme?>(null) }
     var editTarget by remember { mutableStateOf<SavedTheme?>(null) }
     var showRestartDialog by remember { mutableStateOf(false) }
-    var savedThemesVersion by remember { mutableIntStateOf(0) }
-    val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
+    val savedThemes = state.savedThemes
 
     val exportLauncher = rememberLauncherForActivityResult(
         ActivityResultContracts.CreateDocument("application/zip")
@@ -91,6 +90,7 @@ fun ThemeManageScreen(
                     uri = it.toString(),
                     themeName = target?.name,
                     themeData = target?.data,
+                    savedTheme = target,
                 )
             )
         }
@@ -115,10 +115,6 @@ fun ThemeManageScreen(
     LaunchedEffect(Unit) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
-                ThemeManageEffect.SavedThemesChanged -> {
-                    savedThemesVersion++
-                }
-
                 ThemeManageEffect.RestartRequired -> {
                     showRestartDialog = true
                 }
@@ -132,9 +128,6 @@ fun ThemeManageScreen(
                         }
                     }
                     context.toastOnUi(message)
-                    if (effect.savedThemesChanged) {
-                        savedThemesVersion++
-                    }
                     if (effect.restartRequired) {
                         showRestartDialog = true
                     }
@@ -302,8 +295,7 @@ fun ThemeManageScreen(
         confirmText = stringResource(R.string.delete),
         onConfirm = {
             deleteTarget?.let { theme ->
-                ThemeImportExport.deleteSavedTheme(theme)
-                savedThemesVersion++
+                viewModel.onIntent(ThemeManageIntent.DeleteSavedTheme(theme))
             }
             deleteTarget = null
         },

+ 80 - 10
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt

@@ -2,49 +2,88 @@ package io.legado.app.ui.config.themeManage
 
 import android.net.Uri
 import androidx.annotation.StringRes
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.help.config.SavedTheme
 import io.legado.app.help.config.ThemeExportData
-import io.legado.app.help.config.ThemeImportExport
 import io.legado.app.help.config.ThemePackageManager
-import kotlinx.coroutines.Dispatchers
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
 class ThemeManageViewModel(
     private val themePackageManager: ThemePackageManager,
 ) : ViewModel() {
 
+    private val _uiState = MutableStateFlow(ThemeManageUiState())
+    val uiState = _uiState.asStateFlow()
+
     private val _effects = MutableSharedFlow<ThemeManageEffect>(extraBufferCapacity = 8)
     val effects = _effects.asSharedFlow()
 
+    init {
+        loadSavedThemes()
+    }
+
     fun onIntent(intent: ThemeManageIntent) {
         when (intent) {
+            ThemeManageIntent.LoadSavedThemes -> loadSavedThemes()
             is ThemeManageIntent.ExportPackage -> exportPackage(intent)
             is ThemeManageIntent.ImportPackage -> importPackage(intent.uri)
             is ThemeManageIntent.ImportLegacyJson -> importLegacyJson(intent.uri)
             is ThemeManageIntent.SaveTheme -> saveTheme(intent)
             is ThemeManageIntent.ApplySavedTheme -> applySavedTheme(intent.theme)
+            is ThemeManageIntent.DeleteSavedTheme -> deleteSavedTheme(intent.theme)
+        }
+    }
+
+    private fun loadSavedThemes() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(loading = true) }
+            val themes = themePackageManager.loadSavedThemes()
+            _uiState.update {
+                it.copy(
+                    loading = false,
+                    savedThemes = themes.toImmutableList(),
+                )
+            }
+        }
+    }
+
+    private suspend fun refreshSavedThemes() {
+        val themes = themePackageManager.loadSavedThemes()
+        _uiState.update {
+            it.copy(
+                loading = false,
+                savedThemes = themes.toImmutableList(),
+            )
         }
     }
 
     private fun saveTheme(intent: ThemeManageIntent.SaveTheme) {
-        viewModelScope.launch(Dispatchers.IO) {
+        viewModelScope.launch {
+            _uiState.update { it.copy(loading = true) }
             val result = runCatching {
                 themePackageManager.saveTheme(
                     name = intent.name,
-                    data = intent.data ?: ThemeImportExport.exportFromCurrent(),
+                    data = intent.data,
                 )
                 intent.replacedTheme
                     ?.takeIf { it.name != intent.name }
-                    ?.let(ThemeImportExport::deleteSavedTheme)
+                    ?.let { themePackageManager.deleteSavedTheme(it).getOrThrow() }
             }
             if (result.isSuccess) {
-                _effects.emit(ThemeManageEffect.SavedThemesChanged)
+                refreshSavedThemes()
             } else {
+                _uiState.update { it.copy(loading = false) }
                 _effects.emit(
                     ThemeManageEffect.ShowResult(
                         messageRes = R.string.theme_manage_save_failed,
@@ -57,11 +96,13 @@ class ThemeManageViewModel(
 
     private fun applySavedTheme(theme: SavedTheme) {
         viewModelScope.launch {
+            _uiState.update { it.copy(loading = true) }
             val result = themePackageManager.applySavedTheme(theme)
             if (result.isSuccess) {
-                _effects.emit(ThemeManageEffect.SavedThemesChanged)
+                refreshSavedThemes()
                 _effects.emit(ThemeManageEffect.RestartRequired)
             } else {
+                _uiState.update { it.copy(loading = false) }
                 _effects.emit(
                     ThemeManageEffect.ShowResult(
                         messageRes = R.string.theme_manage_apply_failed,
@@ -72,12 +113,31 @@ class ThemeManageViewModel(
         }
     }
 
+    private fun deleteSavedTheme(theme: SavedTheme) {
+        viewModelScope.launch {
+            _uiState.update { it.copy(loading = true) }
+            val result = themePackageManager.deleteSavedTheme(theme)
+            if (result.isSuccess) {
+                refreshSavedThemes()
+            } else {
+                _uiState.update { it.copy(loading = false) }
+                _effects.emit(
+                    ThemeManageEffect.ShowResult(
+                        messageRes = R.string.theme_manage_delete_failed,
+                        detail = result.exceptionOrNull()?.localizedMessage,
+                    )
+                )
+            }
+        }
+    }
+
     private fun exportPackage(intent: ThemeManageIntent.ExportPackage) {
         viewModelScope.launch {
             val result = themePackageManager.exportPackage(
                 uri = Uri.parse(intent.uri),
                 themeName = intent.themeName,
                 themeData = intent.themeData,
+                savedTheme = intent.savedTheme,
             )
             _effects.emit(
                 if (result.isSuccess) {
@@ -105,12 +165,14 @@ class ThemeManageViewModel(
     }
 
     private suspend fun emitImportResult(result: Result<Unit>) {
+        if (result.isSuccess) {
+            refreshSavedThemes()
+        }
         _effects.emit(
             if (result.isSuccess) {
                 ThemeManageEffect.ShowResult(
                     messageRes = R.string.theme_manage_import_success,
                     restartRequired = true,
-                    savedThemesChanged = true,
                 )
             } else {
                 ThemeManageEffect.ShowResult(
@@ -122,11 +184,20 @@ class ThemeManageViewModel(
     }
 }
 
+@Stable
+data class ThemeManageUiState(
+    val loading: Boolean = false,
+    val savedThemes: ImmutableList<SavedTheme> = persistentListOf(),
+)
+
 sealed interface ThemeManageIntent {
+    data object LoadSavedThemes : ThemeManageIntent
+
     data class ExportPackage(
         val uri: String,
         val themeName: String? = null,
         val themeData: ThemeExportData? = null,
+        val savedTheme: SavedTheme? = null,
     ) : ThemeManageIntent
 
     data class ImportPackage(val uri: String) : ThemeManageIntent
@@ -138,16 +209,15 @@ sealed interface ThemeManageIntent {
     ) : ThemeManageIntent
 
     data class ApplySavedTheme(val theme: SavedTheme) : ThemeManageIntent
+    data class DeleteSavedTheme(val theme: SavedTheme) : ThemeManageIntent
 }
 
 sealed interface ThemeManageEffect {
-    data object SavedThemesChanged : ThemeManageEffect
     data object RestartRequired : ThemeManageEffect
 
     data class ShowResult(
         @param:StringRes val messageRes: Int,
         val detail: String? = null,
         val restartRequired: Boolean = false,
-        val savedThemesChanged: Boolean = false,
     ) : ThemeManageEffect
 }

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1438,6 +1438,7 @@
     <string name="theme_manage_import_failed">主题导入失败</string>
     <string name="theme_manage_save_failed">主题保存失败</string>
     <string name="theme_manage_apply_failed">主题应用失败</string>
+    <string name="theme_manage_delete_failed">主题删除失败</string>
     <string name="theme_manage_save_current">保存当前设置</string>
     <string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
     <string name="theme_manage_export_current">导出当前主题</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2125,6 +2125,7 @@
     <string name="theme_manage_import_success">主題已匯入。部分更改需要重啟。</string>
     <string name="theme_manage_save_failed">主題儲存失敗</string>
     <string name="theme_manage_apply_failed">主題套用失敗</string>
+    <string name="theme_manage_delete_failed">主題刪除失敗</string>
     <string name="theme_manage_label_container_color">標籤容器色</string>
     <string name="theme_manage_label_visibility">標籤可見性</string>
     <string name="theme_manage_name_hint">輸入主題名稱</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1585,6 +1585,7 @@
     <string name="theme_manage_import_failed">無法匯入主題</string>
     <string name="theme_manage_save_failed">無法儲存主題</string>
     <string name="theme_manage_apply_failed">無法套用主題</string>
+    <string name="theme_manage_delete_failed">無法刪除主題</string>
     <string name="theme_manage_save_current">儲存目前主題</string>
     <string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
     <string name="theme_manage_export_current">匯出目前主題</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -1473,6 +1473,7 @@
     <string name="theme_manage_import_failed">Could not import theme</string>
     <string name="theme_manage_save_failed">Could not save theme</string>
     <string name="theme_manage_apply_failed">Could not apply theme</string>
+    <string name="theme_manage_delete_failed">Could not delete theme</string>
     <string name="theme_manage_save_current">Save Current Theme</string>
     <string name="theme_manage_save_current_summary">Save the current appearance settings as a theme</string>
     <string name="theme_manage_export_current">Export Current Theme</string>