diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index 4a93b4fb..8fb5005d 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -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) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt index f1456400..2a96beb1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt @@ -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( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt index 5004c4af..fb5d7ac0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt @@ -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) } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index a16a680f..c3f606ef 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -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 { + val route: ImportThemeSettingsRoute = it.toRoute() ?: return@composable + ImportThemeSettingsScreen(route.fromUri.toUri()) + } composable("settings/homescreen") { HomescreenSettingsScreen() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt index 78e76096..50d9df93 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt @@ -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") diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreen.kt new file mode 100644 index 00000000..133900ca --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreen.kt @@ -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 = {} + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreenVM.kt new file mode 100644 index 00000000..e39af082 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ImportThemeSettingsScreenVM.kt @@ -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() + private val uiSettings by inject() + + var themeBundle by mutableStateOf(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 + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt index df18dcd7..33fa1e05 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt @@ -97,7 +97,8 @@ fun CorePaletteColorPreference( value = currentValue == null, onValueChanged = { currentValue = if (it) null else defaultValue - } + }, + containerColor = Color.Transparent, ) AnimatedVisibility( currentValue != null, diff --git a/core/base/src/main/java/de/mm20/launcher2/serialization/ColorIntAsHexSerializer.kt b/core/base/src/main/java/de/mm20/launcher2/serialization/ColorIntAsHexSerializer.kt new file mode 100644 index 00000000..999c00a8 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/serialization/ColorIntAsHexSerializer.kt @@ -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 { + 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')) + } +} \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/serialization/UUIDSerializer.kt b/core/base/src/main/java/de/mm20/launcher2/serialization/UUIDSerializer.kt new file mode 100644 index 00000000..9d810a85 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/serialization/UUIDSerializer.kt @@ -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 { + 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) + } + } +} \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 43853f36..bce8c4e6 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -828,6 +828,7 @@ Custom Restore default Apply theme + Theme already exists and will be updated 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. Unavailable Locked @@ -1032,6 +1033,7 @@ Congratulations, you\'ve locked yourself out! You\'ve discovered a combination of settings that makes both the search and settings inaccessible — effectively locking you out of the launcher. Export theme + Import theme Name Author \ No newline at end of file diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/Colors.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/Colors.kt index 9ae19cc0..1dbf4804 100644 --- a/data/themes/src/main/java/de/mm20/launcher2/themes/Colors.kt +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/Colors.kt @@ -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( val primary: T, @@ -277,50 +280,50 @@ data class CorePalette( val error: T, ) -val EmptyCorePalette = CorePalette(null, null, null, null, null, null) +val EmptyCorePalette = CorePalette(null, null, null, null, null, null) -typealias FullCorePalette = CorePalette -typealias PartialCorePalette = CorePalette +typealias FullCorePalette = CorePalette +typealias PartialCorePalette = CorePalette @Serializable data class ColorScheme( - 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 diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt index ce429c0f..322984c4 100644 --- a/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt @@ -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 { +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 { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING) override fun serialize( @@ -18,12 +35,12 @@ internal class ColorSerializer: KSerializer { } 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 { +internal object ShapeSerializer: KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING) override fun serialize( diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/Shapes.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/Shapes.kt index f876b2b3..74c5d284 100644 --- a/data/themes/src/main/java/de/mm20/launcher2/themes/Shapes.kt +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/Shapes.kt @@ -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( diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt index 319f675b..b7f2eab4 100644 --- a/data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt @@ -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(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>(it) + } + val lightColorScheme = (jsonElement["lightColorScheme"] as? JsonObject)?.let { + LegacyThemeJson.decodeFromJsonElement>(it) + } + val darkColorScheme = (jsonElement["darkColorScheme"] as? JsonObject)?.let { + LegacyThemeJson.decodeFromJsonElement>(it) + } + + val colorScheme = Colors( + id = UUID.randomUUID(), + name = name, + corePalette = corePalette ?: EmptyCorePalette, + lightColorScheme = lightColorScheme ?: DefaultLightColorScheme, + darkColorScheme = darkColorScheme ?: DefaultDarkColorScheme, + ) return ThemeBundle( name = colorScheme.name, author = "",