Add import for new color scheme format

This commit is contained in:
MM20 2025-06-07 14:06:09 +02:00
parent f21feba000
commit 889aa37915
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
15 changed files with 642 additions and 64 deletions

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.kotlin.plugin.compose)
}

View File

@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -46,7 +45,6 @@ import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme
@Composable
@ -61,6 +59,7 @@ fun SearchBar(
onUnfocus: () -> Unit = {},
reverse: Boolean = false,
darkColors: Boolean = false,
readOnly: Boolean = false,
menu: @Composable RowScope.() -> Unit = {},
actions: @Composable ColumnScope.() -> Unit = {},
onKeyboardActionGo: (KeyboardActionScope.() -> Unit)? = null
@ -190,7 +189,8 @@ fun SearchBar(
),
keyboardActions = KeyboardActions(
onGo = onKeyboardActionGo,
)
),
readOnly = readOnly,
)
}
Row(

View File

@ -6,6 +6,7 @@ import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.common.ImportThemeSheet
import de.mm20.launcher2.ui.overlays.OverlayHost
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme
class ImportThemeActivity : BaseActivity() {
@ -19,10 +20,7 @@ class ImportThemeActivity : BaseActivity() {
LauncherTheme {
ProvideSettings {
OverlayHost {
ImportThemeSheet(
onDismiss = { finish() },
uri = uri,
)
ImportThemeSettingsScreen(uri)
}
}
}

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.ui.settings
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
@ -22,11 +23,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.toRoute
import de.mm20.launcher2.licenses.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses
import de.mm20.launcher2.ui.base.BaseActivity
@ -38,6 +41,8 @@ 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.appearance.ImportThemeSettingsRoute
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
@ -163,6 +168,10 @@ class SettingsActivity : BaseActivity() {
composable("settings/appearance/export") {
ExportThemeSettingsScreen()
}
composable<ImportThemeSettingsRoute> {
val route: ImportThemeSettingsRoute = it.toRoute() ?: return@composable
ImportThemeSettingsScreen(route.fromUri.toUri())
}
composable("settings/homescreen") {
HomescreenSettingsScreen()
}

View File

@ -1,5 +1,7 @@
package de.mm20.launcher2.ui.settings.appearance
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowCircleDown
import androidx.compose.material.icons.rounded.ArrowCircleUp
@ -35,6 +37,14 @@ fun AppearanceSettingsScreen() {
val colorThemeName by viewModel.colorThemeName.collectAsStateWithLifecycle(null)
val shapeThemeName by viewModel.shapeThemeName.collectAsStateWithLifecycle(null)
val compatModeColors by viewModel.compatModeColors.collectAsState()
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it == null) {
return@rememberLauncherForActivityResult
}
navController?.navigate(ImportThemeSettingsRoute(it.toString()))
}
PreferenceScreen(title = stringResource(id = R.string.preference_screen_appearance)) {
item {
PreferenceCategory {
@ -105,11 +115,14 @@ fun AppearanceSettingsScreen() {
item {
PreferenceCategory {
Preference(
title = "Import",
title = stringResource(R.string.theme_import_title),
icon = Icons.Rounded.ArrowCircleDown,
onClick = {
importLauncher.launch(arrayOf("*/*"))
}
)
Preference(
title = "Export",
title = stringResource(R.string.theme_export_title),
icon = Icons.Rounded.ArrowCircleUp,
onClick = {
navController?.navigate("settings/appearance/export")

View File

@ -0,0 +1,350 @@
package de.mm20.launcher2.ui.settings.appearance
import android.net.Uri
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
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.Spacer
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.ChangeCircle
import androidx.compose.material.icons.rounded.CropSquare
import androidx.compose.material.icons.rounded.DarkMode
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.PublishedWithChanges
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Upgrade
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.InnerShadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.SearchBar
import de.mm20.launcher2.ui.component.SearchBarLevel
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.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.theme.colorscheme.darkColorSchemeOf
import de.mm20.launcher2.ui.theme.colorscheme.lightColorSchemeOf
import de.mm20.launcher2.ui.theme.shapes.shapesOf
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
data class ImportThemeSettingsRoute(val fromUri: String)
@Composable
fun ImportThemeSettingsScreen(
fromUri: Uri,
) {
val context = LocalContext.current
val activity = LocalActivity.current
val viewModel: ImportThemeSettingsScreenVM = viewModel()
val scope = rememberCoroutineScope()
val themeBundle = viewModel.themeBundle
LaunchedEffect(fromUri) {
viewModel.init(context, fromUri)
}
PreferenceScreen(title = stringResource(R.string.theme_import_title)) {
if (viewModel.error) {
item {
PreferenceCategory {
Banner(
text = stringResource(R.string.import_theme_error),
icon = Icons.Rounded.ErrorOutline,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
)
}
}
} else if (themeBundle != null) {
item {
PreferenceCategory {
Preference(
title = themeBundle.name,
summary = themeBundle.author?.takeIf { it.isNotBlank() })
}
}
item {
val isDarkMode = LocalDarkTheme.current
var darkModePreview by remember { mutableStateOf(isDarkMode) }
MaterialTheme(
colorScheme = themeBundle.colors?.let {
if (darkModePreview) darkColorSchemeOf(it) else lightColorSchemeOf(it)
} ?: MaterialTheme.colorScheme,
shapes = themeBundle.shapes?.let { shapesOf(it) } ?: MaterialTheme.shapes,
) {
ThemePreview(
darkMode = darkModePreview,
onDarkModeChanged = { darkModePreview = it }
)
}
}
item {
PreferenceCategory {
if (themeBundle.colors != null) {
Preference(
icon = Icons.Rounded.Palette,
title = stringResource(R.string.preference_screen_colors),
summary = themeBundle.colors?.name,
controls = if (viewModel.colorsExists) {
{
Icon(Icons.Rounded.ChangeCircle, null)
}
} else null,
)
}
if (themeBundle.shapes != null) {
Preference(
icon = Icons.Rounded.CropSquare,
title = stringResource(R.string.preference_screen_shapes),
summary = themeBundle.shapes?.name,
controls = if (viewModel.shapesExists) {
{
Icon(Icons.Rounded.ChangeCircle, null)
}
} else null,
)
}
if (viewModel.colorsExists || viewModel.shapesExists) {
Banner(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(16.dp),
icon = Icons.Rounded.ChangeCircle,
text = stringResource(R.string.import_theme_exists)
)
}
}
}
item {
PreferenceCategory {
SwitchPreference(
title = stringResource(R.string.import_theme_apply),
value = viewModel.applyTheme,
onValueChanged = { viewModel.applyTheme = it },
)
Button(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(16.dp),
enabled = !viewModel.loading,
onClick = {
scope.launch {
viewModel.import()?.join()
activity?.onBackPressed()
}
}
) {
Text(stringResource(R.string.action_import))
}
}
}
}
}
}
@Composable
private fun ThemePreview(
darkMode: Boolean,
onDarkModeChanged: (Boolean) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.shapes.medium
)
.innerShadow(
MaterialTheme.shapes.medium,
InnerShadow(8.dp, color = Color(0f, 0f, 0f, 0.2f))
)
) {
SearchBar(
style = SearchBarStyle.Solid,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, start = 12.dp, end = 12.dp),
level = SearchBarLevel.Active,
value = "",
onValueChange = {},
readOnly = true,
menu = {
IconButton(onClick = {}) {
Icon(Icons.Rounded.MoreVert, null)
}
}
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, start = 12.dp, end = 12.dp)
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium)
.padding(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.shapes.small
),
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Title",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"Subtitle",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary
)
Text(
"Body",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = true,
label = { Text("Chip") },
leadingIcon = {
Icon(
Icons.Rounded.Star, null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
onClick = { },
)
FilterChip(
selected = false,
label = { Text("Chip") },
leadingIcon = {
Icon(
Icons.Rounded.Star, null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
onClick = { },
)
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = {}) {
Icon(Icons.Rounded.Edit, null)
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
Button(onClick = {}) { Text("Button") }
OutlinedButton(onClick = {}) { Text("Button") }
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
contentAlignment = Alignment.CenterEnd,
) {
SingleChoiceSegmentedButtonRow {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
selected = !darkMode,
onClick = { onDarkModeChanged(false) }
) {
Icon(Icons.Rounded.LightMode, null)
}
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
selected = darkMode,
onClick = { onDarkModeChanged(true) }
) {
Icon(Icons.Rounded.DarkMode, null)
}
}
}
}
}
@Preview
@Composable
private fun ThemePreviewPreview() {
ThemePreview(
darkMode = false,
onDarkModeChanged = {}
)
}

View File

@ -0,0 +1,109 @@
package de.mm20.launcher2.ui.settings.appearance
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.preferences.ColorsDescriptor
import de.mm20.launcher2.preferences.ShapesDescriptor
import de.mm20.launcher2.preferences.ui.UiSettings
import de.mm20.launcher2.themes.ThemeBundle
import de.mm20.launcher2.themes.ThemeRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.getValue
class ImportThemeSettingsScreenVM: ViewModel(), KoinComponent {
private val themeRepository by inject<ThemeRepository>()
private val uiSettings by inject<UiSettings>()
var themeBundle by mutableStateOf<ThemeBundle?>(null)
private set
var colorsExists by mutableStateOf(false)
private set
var shapesExists by mutableStateOf(false)
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf(false)
private set
var applyTheme by mutableStateOf(true)
fun init(context: Context, fromUri: Uri) {
themeBundle = null
error = false
applyTheme = true
loading = true
viewModelScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openInputStream(fromUri)?.reader()?.use {
val text = it.readText()
val theme = ThemeBundle.fromJson(text)
if (theme != null) {
val colors = theme.colors?.id?.let { themeRepository.getColors(it) }?.first()
val shapes = theme.shapes?.id?.let { themeRepository.getShapes(it) }?.first()
colorsExists = colors != null
shapesExists = shapes != null
themeBundle = theme
loading = false
} else {
error = true
}
}
} catch (e: SecurityException) {
CrashReporter.logException(e)
error = true
}
}
}
fun import(): Job? {
val themeBundle = this.themeBundle ?: return null
val colors = themeBundle.colors
val shapes = themeBundle.shapes
val colorsExist = this.colorsExists
val shapesExist = this.shapesExists
loading = true
return viewModelScope.launch {
if (colors != null) {
if (colorsExist) {
themeRepository.updateColors(colors)
} else {
themeRepository.createColors(colors)
}
if (applyTheme) {
uiSettings.setColors(ColorsDescriptor.Custom(colors.id.toString()))
}
}
if (shapes != null) {
if (shapesExist) {
themeRepository.updateShapes(shapes)
} else {
themeRepository.createShapes(shapes)
}
if (applyTheme) {
uiSettings.setShapes(ShapesDescriptor.Custom(shapes.id.toString()))
}
}
loading = false
}
}
}

View File

@ -97,7 +97,8 @@ fun CorePaletteColorPreference(
value = currentValue == null,
onValueChanged = {
currentValue = if (it) null else defaultValue
}
},
containerColor = Color.Transparent,
)
AnimatedVisibility(
currentValue != null,

View File

@ -0,0 +1,22 @@
package de.mm20.launcher2.serialization
import androidx.core.graphics.toColorInt
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object ColorIntAsHexSerializer : KSerializer<Int> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): Int {
return decoder.decodeString().toColorInt()
}
override fun serialize(encoder: Encoder, value: Int) {
encoder.encodeString("#" + value.toUInt().toString(16).padStart(8, '0'))
}
}

View File

@ -0,0 +1,30 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.UUID
object UUIDSerializer: KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
"UUIDSerializer",
PrimitiveKind.STRING
)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
val string = decoder.decodeString()
return try {
UUID.fromString(string)
} catch (e: IllegalArgumentException) {
throw SerializationException("Invalid UUID format: $string", e)
}
}
}

View File

@ -828,6 +828,7 @@
<string name="theme_color_scheme_custom_color">Custom</string>
<string name="preference_restore_default">Restore default</string>
<string name="import_theme_apply">Apply theme</string>
<string name="import_theme_exists">Theme already exists and will be updated</string>
<string name="import_theme_error">The selected file could not be read. Please make sure that you selected a valid theme file (*.kvtheme), and that the file is not corrupt.</string>
<string name="shortcut_label_unavailable">Unavailable</string>
<string name="app_label_locked_profile">Locked</string>
@ -1032,6 +1033,7 @@
<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_import_title">Import theme</string>
<string name="theme_bundle_name">Name</string>
<string name="theme_bundle_author">Author</string>
</resources>

View File

@ -1,14 +1,16 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.database.entities.ColorsEntity
import de.mm20.launcher2.serialization.ColorIntAsHexSerializer
import de.mm20.launcher2.serialization.UUIDSerializer
import hct.Hct
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
@Serializable
data class Colors(
@Transient val id: UUID = UUID.randomUUID(),
@Serializable(with = UUIDSerializer::class) val id: UUID = UUID.randomUUID(),
val builtIn: Boolean = false,
val name: String,
val corePalette: PartialCorePalette = EmptyCorePalette,
@ -229,7 +231,6 @@ enum class CorePaletteColor {
}
}
@Serializable(with = ColorSerializer::class)
sealed interface Color {
companion object {
fun fromString(string: String?): Color? {
@ -267,6 +268,8 @@ value class StaticColor(val color: Int) : Color {
}
}
typealias CorePaletteColorValue = @Serializable(with = ColorIntAsHexSerializer::class) Int
@Serializable
data class CorePalette<out T : Int?>(
val primary: T,
@ -277,50 +280,50 @@ data class CorePalette<out T : Int?>(
val error: T,
)
val EmptyCorePalette = CorePalette<Int?>(null, null, null, null, null, null)
val EmptyCorePalette = CorePalette<CorePaletteColorValue?>(null, null, null, null, null, null)
typealias FullCorePalette = CorePalette<Int>
typealias PartialCorePalette = CorePalette<Int?>
typealias FullCorePalette = CorePalette<CorePaletteColorValue>
typealias PartialCorePalette = CorePalette<CorePaletteColorValue?>
@Serializable
data class ColorScheme<out T : Color?>(
val primary: T,
val onPrimary: T,
val primaryContainer: T,
val onPrimaryContainer: T,
val secondary: T,
val onSecondary: T,
val secondaryContainer: T,
val onSecondaryContainer: T,
val tertiary: T,
val onTertiary: T,
val tertiaryContainer: T,
val onTertiaryContainer: T,
val error: T,
val onError: T,
val errorContainer: T,
val onErrorContainer: T,
val surface: T,
val onSurface: T,
val onSurfaceVariant: T,
val outline: T,
val outlineVariant: T,
val inverseSurface: T,
val inverseOnSurface: T,
val inversePrimary: T,
val surfaceDim: T,
val surfaceBright: T,
val surfaceContainerLowest: T,
val surfaceContainerLow: T,
val surfaceContainer: T,
val surfaceContainerHigh: T,
val surfaceContainerHighest: T,
@Contextual val primary: T,
@Contextual val onPrimary: T,
@Contextual val primaryContainer: T,
@Contextual val onPrimaryContainer: T,
@Contextual val secondary: T,
@Contextual val onSecondary: T,
@Contextual val secondaryContainer: T,
@Contextual val onSecondaryContainer: T,
@Contextual val tertiary: T,
@Contextual val onTertiary: T,
@Contextual val tertiaryContainer: T,
@Contextual val onTertiaryContainer: T,
@Contextual val error: T,
@Contextual val onError: T,
@Contextual val errorContainer: T,
@Contextual val onErrorContainer: T,
@Contextual val surface: T,
@Contextual val onSurface: T,
@Contextual val onSurfaceVariant: T,
@Contextual val outline: T,
@Contextual val outlineVariant: T,
@Contextual val inverseSurface: T,
@Contextual val inverseOnSurface: T,
@Contextual val inversePrimary: T,
@Contextual val surfaceDim: T,
@Contextual val surfaceBright: T,
@Contextual val surfaceContainerLowest: T,
@Contextual val surfaceContainerLow: T,
@Contextual val surfaceContainer: T,
@Contextual val surfaceContainerHigh: T,
@Contextual val surfaceContainerHighest: T,
val background: T,
val onBackground: T,
val surfaceTint: T,
val scrim: T,
val surfaceVariant: T,
@Contextual val background: T,
@Contextual val onBackground: T,
@Contextual val surfaceTint: T,
@Contextual val scrim: T,
@Contextual val surfaceVariant: T,
)
typealias FullColorScheme = ColorScheme<Color>

View File

@ -6,8 +6,25 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
internal class ColorSerializer: KSerializer<Color> {
internal val module = SerializersModule {
contextual(Color::class, ColorSerializer)
}
val ThemeJson = Json {
serializersModule = module
encodeDefaults = true
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
coerceInputValues = true
}
internal object ColorSerializer: KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING)
override fun serialize(
@ -18,12 +35,12 @@ internal class ColorSerializer: KSerializer<Color> {
}
override fun deserialize(decoder: Decoder): Color {
TODO("Not yet implemented")
val stringValue = decoder.decodeString()
return Color.fromString(stringValue) ?: StaticColor(0xFF000000.toInt())
}
}
}
internal class ShapeSerializer: KSerializer<Shape> {
internal object ShapeSerializer: KSerializer<Shape> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING)
override fun serialize(

View File

@ -1,13 +1,13 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.database.entities.ShapesEntity
import de.mm20.launcher2.serialization.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
@Serializable
data class Shapes(
@Transient val id: UUID = UUID.randomUUID(),
@Serializable(with = UUIDSerializer::class) val id: UUID = UUID.randomUUID(),
val builtIn: Boolean = false,
val name: String,
val baseShape: Shape = Shape(

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.themes
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.Dispatchers
@ -7,10 +8,13 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import java.util.UUID
@Serializable
data class ThemeBundle(
@ -24,21 +28,23 @@ data class ThemeBundle(
val version: Int = 2,
) {
fun toJson(): String {
return Json.Lenient.encodeToString(this)
return ThemeJson.encodeToString(this)
}
companion object {
fun fromJson(jsonString: String): ThemeBundle? {
try {
val jsonElement = Json.Lenient.parseToJsonElement(jsonString).jsonObject
val jsonElement = ThemeJson.parseToJsonElement(jsonString).jsonObject
val version = (jsonElement.get("version") as? JsonPrimitive)?.intOrNull
val version = (jsonElement["version"] as? JsonPrimitive)?.intOrNull
if (version != 2) {
return fromLegacyJson(jsonElement)
}
return Json.Lenient.decodeFromJsonElement(jsonElement)
return ThemeJson.decodeFromJsonElement<ThemeBundle>(jsonElement).also {
Log.d("MM20", "$it")
}
} catch (e: SerializationException) {
CrashReporter.logException(e)
@ -49,9 +55,26 @@ data class ThemeBundle(
}
}
private fun fromLegacyJson(jsonElement: JsonElement): ThemeBundle? {
private fun fromLegacyJson(jsonElement: JsonObject): ThemeBundle? {
try {
val colorScheme: Colors = LegacyThemeJson.decodeFromJsonElement(jsonElement)
val name = (jsonElement["name"] as? JsonPrimitive)?.contentOrNull ?: return null
val corePalette = (jsonElement["corePalette"] as? JsonObject)?.let {
LegacyThemeJson.decodeFromJsonElement<CorePalette<Int?>>(it)
}
val lightColorScheme = (jsonElement["lightColorScheme"] as? JsonObject)?.let {
LegacyThemeJson.decodeFromJsonElement<ColorScheme<Color?>>(it)
}
val darkColorScheme = (jsonElement["darkColorScheme"] as? JsonObject)?.let {
LegacyThemeJson.decodeFromJsonElement<ColorScheme<Color?>>(it)
}
val colorScheme = Colors(
id = UUID.randomUUID(),
name = name,
corePalette = corePalette ?: EmptyCorePalette,
lightColorScheme = lightColorScheme ?: DefaultLightColorScheme,
darkColorScheme = darkColorScheme ?: DefaultDarkColorScheme,
)
return ThemeBundle(
name = colorScheme.name,
author = "",