New theme file format and exporter

(But no import yet)

Close #1443
This commit is contained in:
MM20 2025-06-04 23:56:00 +02:00
parent 28232a33b4
commit 5ac4022eb5
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
9 changed files with 377 additions and 27 deletions

View File

@ -37,6 +37,7 @@ import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import de.mm20.launcher2.ui.overlays.OverlayHost
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.ExportThemeSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
@ -159,6 +160,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/appearance") {
AppearanceSettingsScreen()
}
composable("settings/appearance/export") {
ExportThemeSettingsScreen()
}
composable("settings/homescreen") {
HomescreenSettingsScreen()
}

View File

@ -1,6 +1,8 @@
package de.mm20.launcher2.ui.settings.appearance
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowCircleDown
import androidx.compose.material.icons.rounded.ArrowCircleUp
import androidx.compose.material.icons.rounded.CropSquare
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.TextFields
@ -100,6 +102,22 @@ fun AppearanceSettingsScreen() {
}
}
item {
PreferenceCategory {
Preference(
title = "Import",
icon = Icons.Rounded.ArrowCircleDown,
)
Preference(
title = "Export",
icon = Icons.Rounded.ArrowCircleUp,
onClick = {
navController?.navigate("settings/appearance/export")
}
)
}
}
if (isAtLeastApiLevel(31)) {
item {
PreferenceCategory(stringResource(R.string.preference_category_advanced)) {

View File

@ -0,0 +1,181 @@
package de.mm20.launcher2.ui.settings.appearance
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CropSquare
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SplitButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.TextPreference
@Composable
fun ExportThemeSettingsScreen() {
val viewModel = viewModel<ExportThemeSettingsScreenVM>()
val context = LocalContext.current
val colorSchemes by viewModel.colorSchemes.collectAsState(emptyList())
val shapeThemes by viewModel.shapeSchemes.collectAsState(emptyList())
val isValidSelection by remember {
derivedStateOf {
viewModel.colorScheme != null || viewModel.shapeScheme != null
}
}
val fileChooserLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.de.mm20.launcher2.theme")) {
if (it != null) viewModel.exportTheme(context, it)
}
LaunchedEffect(Unit) {
viewModel.init()
}
PreferenceScreen(
title = stringResource(R.string.theme_export_title)
) {
item {
PreferenceCategory {
TextPreference(
stringResource(R.string.theme_bundle_name),
value = viewModel.themeName,
summary = viewModel.themeName.takeIf { it.isNotBlank() },
onValueChanged = {
viewModel.themeName = it
}
)
TextPreference(
stringResource(R.string.theme_bundle_author),
value = viewModel.themeAuthor,
summary = viewModel.themeAuthor.takeIf { it.isNotBlank() },
onValueChanged = {
viewModel.themeAuthor = it
}
)
}
}
item {
PreferenceCategory {
ListPreference(
stringResource(R.string.preference_screen_colors),
icon = Icons.Rounded.Palette,
value = viewModel.colorScheme,
items = listOf(stringResource(R.string.no_selection) to null) + colorSchemes.map {
it.name to it
},
onValueChanged = { newValue ->
viewModel.setColorScheme(newValue)
}
)
ListPreference(
stringResource(R.string.preference_screen_shapes),
icon = Icons.Rounded.CropSquare,
value = viewModel.shapeScheme,
items = listOf(stringResource(R.string.no_selection) to null) + shapeThemes.map {
it.name to it
},
onValueChanged = { newValue ->
viewModel.setShapeScheme(newValue)
}
)
}
}
item {
PreferenceCategory {
var showDropdown by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(SplitButtonDefaults.Spacing)
) {
SplitButtonDefaults.LeadingButton(
modifier = Modifier.weight(1f),
enabled = isValidSelection,
onClick = {
viewModel.shareTheme(context)
}
) {
Icon(
Icons.Rounded.Share,
null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(SplitButtonDefaults.LeadingIconSize),
)
Text(stringResource(R.string.menu_share))
}
SplitButtonDefaults.TrailingButton(
onClick = {
showDropdown = !showDropdown
},
enabled = isValidSelection,
) {
val rotation: Float by animateFloatAsState(
targetValue = if (showDropdown) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
Icons.Rounded.KeyboardArrowDown,
modifier =
Modifier
.size(SplitButtonDefaults.TrailingIconSize)
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = null
)
DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.save_as_file)) },
onClick = {
fileChooserLauncher.launch("${viewModel.themeName}.kvtheme")
showDropdown = false
},
leadingIcon = { Icon(Icons.Rounded.Save, contentDescription = null) }
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,95 @@
package de.mm20.launcher2.ui.settings.appearance
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.themes.Colors
import de.mm20.launcher2.themes.Shapes
import de.mm20.launcher2.themes.ThemeBundle
import de.mm20.launcher2.themes.ThemeRepository
import de.mm20.launcher2.themes.toLegacyJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class ExportThemeSettingsScreenVM: ViewModel(), KoinComponent {
private val themeRepository: ThemeRepository by inject()
val colorSchemes = themeRepository.getAllColors().map { it.filter { !it.builtIn } }
val shapeSchemes = themeRepository.getAllShapes().map { it.filter { !it.builtIn } }
var themeName by mutableStateOf("")
var themeAuthor by mutableStateOf("")
fun init() {
themeName = ""
}
var colorScheme by mutableStateOf<Colors?>(null)
@JvmName("_setColorScheme")
private set
fun setColorScheme(scheme: Colors?) {
if (themeName.isBlank() && scheme != null) themeName = scheme.name
colorScheme = scheme
}
var shapeScheme by mutableStateOf<Shapes?>(null)
@JvmName("_setShapeScheme")
private set
fun setShapeScheme(scheme: Shapes?) {
if (themeName.isBlank() && scheme != null) themeName = scheme.name
shapeScheme = scheme
}
private fun getThemeBundle(): ThemeBundle {
return ThemeBundle(
name = themeName,
author = themeAuthor.takeIf { it.isNotBlank() },
colors = colorScheme,
shapes = shapeScheme,
)
}
fun exportTheme(context: Context, uri: Uri) {
val themeBundle = getThemeBundle()
viewModelScope.launch(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.writer()?.use {
it.write(themeBundle.toJson())
}
}
}
fun shareTheme(context: Context) {
val themeBundle = getThemeBundle()
viewModelScope.launch {
val file = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "${themeName}.kvtheme")
file.writeText(themeBundle.toJson())
file
}
context.tryStartActivity(Intent().apply {
action = Intent.ACTION_SEND
type = "application/json"
putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
file
))
}.let { Intent.createChooser(it, null) })
}
}
}

View File

@ -159,7 +159,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
)
},
defaultValue = systemPalette.primary,
modifier = Modifier.padding(end = 12.dp),
)
CorePaletteColorPreference(
title = "Secondary",
@ -174,7 +173,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
)
},
defaultValue = systemPalette.secondary,
modifier = Modifier.padding(end = 12.dp),
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).a2.keyColor.toInt()
@ -194,7 +192,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
)
},
defaultValue = systemPalette.tertiary,
modifier = Modifier.padding(end = 12.dp),
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).a3.keyColor.toInt()
@ -214,7 +211,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
)
},
defaultValue = systemPalette.neutral,
modifier = Modifier.padding(end = 12.dp),
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).n1.keyColor.toInt()
@ -234,7 +230,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
)
},
defaultValue = systemPalette.neutralVariant,
modifier = Modifier.padding(end = 12.dp),
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).n2.keyColor.toInt()

View File

@ -67,24 +67,13 @@ fun ColorSchemesSettingsScreen() {
var deleteColors by remember { mutableStateOf<Colors?>(null) }
var importThemeUri by remember { mutableStateOf<Uri?>(null) }
val importIntentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
importThemeUri = it
}
PreferenceScreen(
title = stringResource(R.string.preference_screen_colors),
topBarActions = {
IconButton(onClick = { importIntentLauncher.launch(arrayOf("*/*")) }) {
Icon(Icons.Rounded.Download, null)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.createNew(context) }) {
IconButton(onClick = { viewModel.createNew(context) }) {
Icon(Icons.Rounded.Add, null)
}
}
},
) {
item {
PreferenceCategory {
@ -192,10 +181,6 @@ fun ColorSchemesSettingsScreen() {
}
)
}
if (importThemeUri != null) {
ImportThemeSheet(uri = importThemeUri!!, onDismiss = { importThemeUri = null })
}
}
@Composable

View File

@ -18,7 +18,6 @@ import androidx.compose.material.icons.rounded.RadioButtonUnchecked
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -43,7 +42,6 @@ import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.theme.shapes.shapesOf
@Composable
fun ShapeSchemesSettingsScreen() {
@ -58,11 +56,11 @@ fun ShapeSchemesSettingsScreen() {
PreferenceScreen(
title = stringResource(R.string.preference_screen_shapes),
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.createNew(context) }) {
topBarActions = {
IconButton(onClick = { viewModel.createNew(context) }) {
Icon(Icons.Rounded.Add, null)
}
}
},
) {
item {
PreferenceCategory {

View File

@ -243,7 +243,9 @@
<string name="easter_egg_text">Well, you found me. Congratulations. Was it worth it\?</string>
<!-- Close a dialog -->
<string name="close">Close</string>
<string name="no_selection">No selection</string>
<string name="save">Save</string>
<string name="save_as_file">Save as file</string>
<string name="duplicate">Duplicate</string>
<string name="edit">Edit</string>
<string name="skip">Skip</string>
@ -1029,4 +1031,7 @@
<string name="departure_time_departed">departed</string>
<string name="bad_configuration_title">Congratulations, you\'ve locked yourself out!</string>
<string name="bad_configuration_summary">You\'ve discovered a combination of settings that makes both the search and settings inaccessible — effectively locking you out of the launcher.</string>
<string name="theme_export_title">Export theme</string>
<string name="theme_bundle_name">Name</string>
<string name="theme_bundle_author">Author</string>
</resources>

View File

@ -0,0 +1,69 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
@Serializable
data class ThemeBundle(
val name: String,
val author: String? = null,
val colors: Colors? = null,
val shapes: Shapes? = null,
/**
* The file version, always 2 for the new theme format.
*/
val version: Int = 2,
) {
fun toJson(): String {
return Json.Lenient.encodeToString(this)
}
companion object {
fun fromJson(jsonString: String): ThemeBundle? {
try {
val jsonElement = Json.Lenient.parseToJsonElement(jsonString).jsonObject
val version = (jsonElement.get("version") as? JsonPrimitive)?.intOrNull
if (version != 2) {
return fromLegacyJson(jsonElement)
}
return Json.Lenient.decodeFromJsonElement(jsonElement)
} catch (e: SerializationException) {
CrashReporter.logException(e)
return null
} catch (e: IllegalArgumentException) {
CrashReporter.logException(e)
return null
}
}
private fun fromLegacyJson(jsonElement: JsonElement): ThemeBundle? {
try {
val colorScheme: Colors = LegacyThemeJson.decodeFromJsonElement(jsonElement)
return ThemeBundle(
name = colorScheme.name,
author = "",
colors = colorScheme,
shapes = null,
version = 2,
)
} catch (e: SerializationException) {
CrashReporter.logException(e)
return null
}
}
}
}