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 { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.kotlin.plugin.compose) 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.layout.BottomReversed import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme
@Composable @Composable
@ -61,6 +59,7 @@ fun SearchBar(
onUnfocus: () -> Unit = {}, onUnfocus: () -> Unit = {},
reverse: Boolean = false, reverse: Boolean = false,
darkColors: Boolean = false, darkColors: Boolean = false,
readOnly: Boolean = false,
menu: @Composable RowScope.() -> Unit = {}, menu: @Composable RowScope.() -> Unit = {},
actions: @Composable ColumnScope.() -> Unit = {}, actions: @Composable ColumnScope.() -> Unit = {},
onKeyboardActionGo: (KeyboardActionScope.() -> Unit)? = null onKeyboardActionGo: (KeyboardActionScope.() -> Unit)? = null
@ -190,7 +189,8 @@ fun SearchBar(
), ),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onGo = onKeyboardActionGo, onGo = onKeyboardActionGo,
) ),
readOnly = readOnly,
) )
} }
Row( 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.base.ProvideSettings
import de.mm20.launcher2.ui.common.ImportThemeSheet import de.mm20.launcher2.ui.common.ImportThemeSheet
import de.mm20.launcher2.ui.overlays.OverlayHost import de.mm20.launcher2.ui.overlays.OverlayHost
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme import de.mm20.launcher2.ui.theme.LauncherTheme
class ImportThemeActivity : BaseActivity() { class ImportThemeActivity : BaseActivity() {
@ -19,10 +20,7 @@ class ImportThemeActivity : BaseActivity() {
LauncherTheme { LauncherTheme {
ProvideSettings { ProvideSettings {
OverlayHost { OverlayHost {
ImportThemeSheet( ImportThemeSettingsScreen(uri)
onDismiss = { finish() },
uri = uri,
)
} }
} }
} }

View File

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

View File

@ -1,5 +1,7 @@
package de.mm20.launcher2.ui.settings.appearance 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.Icons
import androidx.compose.material.icons.rounded.ArrowCircleDown import androidx.compose.material.icons.rounded.ArrowCircleDown
import androidx.compose.material.icons.rounded.ArrowCircleUp import androidx.compose.material.icons.rounded.ArrowCircleUp
@ -35,6 +37,14 @@ fun AppearanceSettingsScreen() {
val colorThemeName by viewModel.colorThemeName.collectAsStateWithLifecycle(null) val colorThemeName by viewModel.colorThemeName.collectAsStateWithLifecycle(null)
val shapeThemeName by viewModel.shapeThemeName.collectAsStateWithLifecycle(null) val shapeThemeName by viewModel.shapeThemeName.collectAsStateWithLifecycle(null)
val compatModeColors by viewModel.compatModeColors.collectAsState() 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)) { PreferenceScreen(title = stringResource(id = R.string.preference_screen_appearance)) {
item { item {
PreferenceCategory { PreferenceCategory {
@ -105,11 +115,14 @@ fun AppearanceSettingsScreen() {
item { item {
PreferenceCategory { PreferenceCategory {
Preference( Preference(
title = "Import", title = stringResource(R.string.theme_import_title),
icon = Icons.Rounded.ArrowCircleDown, icon = Icons.Rounded.ArrowCircleDown,
onClick = {
importLauncher.launch(arrayOf("*/*"))
}
) )
Preference( Preference(
title = "Export", title = stringResource(R.string.theme_export_title),
icon = Icons.Rounded.ArrowCircleUp, icon = Icons.Rounded.ArrowCircleUp,
onClick = { onClick = {
navController?.navigate("settings/appearance/export") 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, value = currentValue == null,
onValueChanged = { onValueChanged = {
currentValue = if (it) null else defaultValue currentValue = if (it) null else defaultValue
} },
containerColor = Color.Transparent,
) )
AnimatedVisibility( AnimatedVisibility(
currentValue != null, 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="theme_color_scheme_custom_color">Custom</string>
<string name="preference_restore_default">Restore default</string> <string name="preference_restore_default">Restore default</string>
<string name="import_theme_apply">Apply theme</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="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="shortcut_label_unavailable">Unavailable</string>
<string name="app_label_locked_profile">Locked</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_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="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_export_title">Export theme</string>
<string name="theme_import_title">Import theme</string>
<string name="theme_bundle_name">Name</string> <string name="theme_bundle_name">Name</string>
<string name="theme_bundle_author">Author</string> <string name="theme_bundle_author">Author</string>
</resources> </resources>

View File

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

View File

@ -6,8 +6,25 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder 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 val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING)
override fun serialize( override fun serialize(
@ -18,12 +35,12 @@ internal class ColorSerializer: KSerializer<Color> {
} }
override fun deserialize(decoder: Decoder): 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 val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING)
override fun serialize( override fun serialize(

View File

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

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.themes package de.mm20.launcher2.themes
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.serialization.Json import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -7,10 +8,13 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import java.util.UUID
@Serializable @Serializable
data class ThemeBundle( data class ThemeBundle(
@ -24,21 +28,23 @@ data class ThemeBundle(
val version: Int = 2, val version: Int = 2,
) { ) {
fun toJson(): String { fun toJson(): String {
return Json.Lenient.encodeToString(this) return ThemeJson.encodeToString(this)
} }
companion object { companion object {
fun fromJson(jsonString: String): ThemeBundle? { fun fromJson(jsonString: String): ThemeBundle? {
try { 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) { if (version != 2) {
return fromLegacyJson(jsonElement) return fromLegacyJson(jsonElement)
} }
return Json.Lenient.decodeFromJsonElement(jsonElement) return ThemeJson.decodeFromJsonElement<ThemeBundle>(jsonElement).also {
Log.d("MM20", "$it")
}
} catch (e: SerializationException) { } catch (e: SerializationException) {
CrashReporter.logException(e) CrashReporter.logException(e)
@ -49,9 +55,26 @@ data class ThemeBundle(
} }
} }
private fun fromLegacyJson(jsonElement: JsonElement): ThemeBundle? { private fun fromLegacyJson(jsonElement: JsonObject): ThemeBundle? {
try { 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( return ThemeBundle(
name = colorScheme.name, name = colorScheme.name,
author = "", author = "",