From 5ac4022eb5cd48040d084c1aec2b1fe337c56775 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:56:00 +0200 Subject: [PATCH] New theme file format and exporter (But no import yet) Close #1443 --- .../launcher2/ui/settings/SettingsActivity.kt | 4 + .../appearance/AppearanceSettingsScreen.kt | 18 ++ .../appearance/ExportThemeSettingsScreen.kt | 181 ++++++++++++++++++ .../appearance/ExportThemeSettingsScreenVM.kt | 95 +++++++++ .../colorscheme/ColorSchemeSettingsScreen.kt | 5 - .../colorscheme/ColorSchemesSettingsScreen.kt | 19 +- .../shapes/ShapeSchemesSettingsScreen.kt | 8 +- core/i18n/src/main/res/values/strings.xml | 5 + .../de/mm20/launcher2/themes/ThemeBundle.kt | 69 +++++++ 9 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreen.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreenVM.kt create mode 100644 data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt 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 d4b59947..a16a680f 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 @@ -37,6 +37,7 @@ import de.mm20.launcher2.ui.locals.LocalWallpaperColors import de.mm20.launcher2.ui.overlays.OverlayHost import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen +import de.mm20.launcher2.ui.settings.appearance.ExportThemeSettingsScreen import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen @@ -159,6 +160,9 @@ class SettingsActivity : BaseActivity() { composable("settings/appearance") { AppearanceSettingsScreen() } + composable("settings/appearance/export") { + ExportThemeSettingsScreen() + } composable("settings/homescreen") { HomescreenSettingsScreen() } 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 2f9c7541..78e76096 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,6 +1,8 @@ package de.mm20.launcher2.ui.settings.appearance import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowCircleDown +import androidx.compose.material.icons.rounded.ArrowCircleUp import androidx.compose.material.icons.rounded.CropSquare import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.TextFields @@ -100,6 +102,22 @@ fun AppearanceSettingsScreen() { } } + item { + PreferenceCategory { + Preference( + title = "Import", + icon = Icons.Rounded.ArrowCircleDown, + ) + Preference( + title = "Export", + icon = Icons.Rounded.ArrowCircleUp, + onClick = { + navController?.navigate("settings/appearance/export") + } + ) + } + } + if (isAtLeastApiLevel(31)) { item { PreferenceCategory(stringResource(R.string.preference_category_advanced)) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreen.kt new file mode 100644 index 00000000..ad0378e0 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreen.kt @@ -0,0 +1,181 @@ +package de.mm20.launcher2.ui.settings.appearance + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CropSquare +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.preferences.ListPreference +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.TextPreference + +@Composable +fun ExportThemeSettingsScreen() { + val viewModel = viewModel() + + val context = LocalContext.current + + val colorSchemes by viewModel.colorSchemes.collectAsState(emptyList()) + val shapeThemes by viewModel.shapeSchemes.collectAsState(emptyList()) + + val isValidSelection by remember { + derivedStateOf { + viewModel.colorScheme != null || viewModel.shapeScheme != null + } + } + + val fileChooserLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.de.mm20.launcher2.theme")) { + if (it != null) viewModel.exportTheme(context, it) + } + + LaunchedEffect(Unit) { + viewModel.init() + } + PreferenceScreen( + title = stringResource(R.string.theme_export_title) + ) { + item { + PreferenceCategory { + TextPreference( + stringResource(R.string.theme_bundle_name), + value = viewModel.themeName, + summary = viewModel.themeName.takeIf { it.isNotBlank() }, + onValueChanged = { + viewModel.themeName = it + } + ) + TextPreference( + stringResource(R.string.theme_bundle_author), + value = viewModel.themeAuthor, + summary = viewModel.themeAuthor.takeIf { it.isNotBlank() }, + onValueChanged = { + viewModel.themeAuthor = it + } + ) + } + } + item { + PreferenceCategory { + ListPreference( + stringResource(R.string.preference_screen_colors), + icon = Icons.Rounded.Palette, + value = viewModel.colorScheme, + items = listOf(stringResource(R.string.no_selection) to null) + colorSchemes.map { + it.name to it + }, + onValueChanged = { newValue -> + viewModel.setColorScheme(newValue) + } + ) + ListPreference( + stringResource(R.string.preference_screen_shapes), + icon = Icons.Rounded.CropSquare, + value = viewModel.shapeScheme, + items = listOf(stringResource(R.string.no_selection) to null) + shapeThemes.map { + it.name to it + }, + onValueChanged = { newValue -> + viewModel.setShapeScheme(newValue) + } + ) + } + } + item { + PreferenceCategory { + var showDropdown by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface, + MaterialTheme.shapes.extraSmall + ) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(SplitButtonDefaults.Spacing) + ) { + SplitButtonDefaults.LeadingButton( + modifier = Modifier.weight(1f), + enabled = isValidSelection, + onClick = { + viewModel.shareTheme(context) + } + ) { + Icon( + Icons.Rounded.Share, + null, + modifier = Modifier + .padding(end = ButtonDefaults.IconSpacing) + .size(SplitButtonDefaults.LeadingIconSize), + ) + Text(stringResource(R.string.menu_share)) + } + SplitButtonDefaults.TrailingButton( + onClick = { + showDropdown = !showDropdown + }, + enabled = isValidSelection, + ) { + val rotation: Float by animateFloatAsState( + targetValue = if (showDropdown) 180f else 0f, + label = "Trailing Icon Rotation" + ) + Icon( + Icons.Rounded.KeyboardArrowDown, + modifier = + Modifier + .size(SplitButtonDefaults.TrailingIconSize) + .graphicsLayer { + this.rotationZ = rotation + }, + contentDescription = null + ) + + DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.save_as_file)) }, + onClick = { + fileChooserLauncher.launch("${viewModel.themeName}.kvtheme") + showDropdown = false + }, + leadingIcon = { Icon(Icons.Rounded.Save, contentDescription = null) } + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreenVM.kt new file mode 100644 index 00000000..60047ea4 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/ExportThemeSettingsScreenVM.kt @@ -0,0 +1,95 @@ +package de.mm20.launcher2.ui.settings.appearance + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.themes.Colors +import de.mm20.launcher2.themes.Shapes +import de.mm20.launcher2.themes.ThemeBundle +import de.mm20.launcher2.themes.ThemeRepository +import de.mm20.launcher2.themes.toLegacyJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +class ExportThemeSettingsScreenVM: ViewModel(), KoinComponent { + + private val themeRepository: ThemeRepository by inject() + + val colorSchemes = themeRepository.getAllColors().map { it.filter { !it.builtIn } } + val shapeSchemes = themeRepository.getAllShapes().map { it.filter { !it.builtIn } } + + var themeName by mutableStateOf("") + var themeAuthor by mutableStateOf("") + + + fun init() { + themeName = "" + } + + var colorScheme by mutableStateOf(null) + @JvmName("_setColorScheme") + private set + fun setColorScheme(scheme: Colors?) { + if (themeName.isBlank() && scheme != null) themeName = scheme.name + colorScheme = scheme + } + + var shapeScheme by mutableStateOf(null) + @JvmName("_setShapeScheme") + private set + fun setShapeScheme(scheme: Shapes?) { + if (themeName.isBlank() && scheme != null) themeName = scheme.name + shapeScheme = scheme + } + + private fun getThemeBundle(): ThemeBundle { + return ThemeBundle( + name = themeName, + author = themeAuthor.takeIf { it.isNotBlank() }, + colors = colorScheme, + shapes = shapeScheme, + ) + } + + fun exportTheme(context: Context, uri: Uri) { + val themeBundle = getThemeBundle() + viewModelScope.launch(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.writer()?.use { + it.write(themeBundle.toJson()) + } + } + } + + fun shareTheme(context: Context) { + val themeBundle = getThemeBundle() + viewModelScope.launch { + val file = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, "${themeName}.kvtheme") + file.writeText(themeBundle.toJson()) + file + } + context.tryStartActivity(Intent().apply { + action = Intent.ACTION_SEND + type = "application/json" + putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".fileprovider", + file + )) + }.let { Intent.createChooser(it, null) }) + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt index 53a0c4a1..642e6bec 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt @@ -159,7 +159,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { ) }, defaultValue = systemPalette.primary, - modifier = Modifier.padding(end = 12.dp), ) CorePaletteColorPreference( title = "Secondary", @@ -174,7 +173,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { ) }, defaultValue = systemPalette.secondary, - modifier = Modifier.padding(end = 12.dp), autoGenerate = { theme!!.corePalette.primary?.let { CorePalette.of(it).a2.keyColor.toInt() @@ -194,7 +192,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { ) }, defaultValue = systemPalette.tertiary, - modifier = Modifier.padding(end = 12.dp), autoGenerate = { theme!!.corePalette.primary?.let { CorePalette.of(it).a3.keyColor.toInt() @@ -214,7 +211,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { ) }, defaultValue = systemPalette.neutral, - modifier = Modifier.padding(end = 12.dp), autoGenerate = { theme!!.corePalette.primary?.let { CorePalette.of(it).n1.keyColor.toInt() @@ -234,7 +230,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { ) }, defaultValue = systemPalette.neutralVariant, - modifier = Modifier.padding(end = 12.dp), autoGenerate = { theme!!.corePalette.primary?.let { CorePalette.of(it).n2.keyColor.toInt() diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemesSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemesSettingsScreen.kt index d4649183..743365a4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemesSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemesSettingsScreen.kt @@ -67,24 +67,13 @@ fun ColorSchemesSettingsScreen() { var deleteColors by remember { mutableStateOf(null) } - var importThemeUri by remember { mutableStateOf(null) } - - val importIntentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { - importThemeUri = it - } - PreferenceScreen( title = stringResource(R.string.preference_screen_colors), topBarActions = { - IconButton(onClick = { importIntentLauncher.launch(arrayOf("*/*")) }) { - Icon(Icons.Rounded.Download, null) - } - }, - floatingActionButton = { - FloatingActionButton(onClick = { viewModel.createNew(context) }) { + IconButton(onClick = { viewModel.createNew(context) }) { Icon(Icons.Rounded.Add, null) } - } + }, ) { item { PreferenceCategory { @@ -192,10 +181,6 @@ fun ColorSchemesSettingsScreen() { } ) } - - if (importThemeUri != null) { - ImportThemeSheet(uri = importThemeUri!!, onDismiss = { importThemeUri = null }) - } } @Composable diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemesSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemesSettingsScreen.kt index 292abf64..97ca9b0c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemesSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemesSettingsScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -43,7 +42,6 @@ import de.mm20.launcher2.ui.component.preferences.Preference import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.locals.LocalNavController -import de.mm20.launcher2.ui.theme.shapes.shapesOf @Composable fun ShapeSchemesSettingsScreen() { @@ -58,11 +56,11 @@ fun ShapeSchemesSettingsScreen() { PreferenceScreen( title = stringResource(R.string.preference_screen_shapes), - floatingActionButton = { - FloatingActionButton(onClick = { viewModel.createNew(context) }) { + topBarActions = { + IconButton(onClick = { viewModel.createNew(context) }) { Icon(Icons.Rounded.Add, null) } - } + }, ) { item { PreferenceCategory { diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index f093e528..43853f36 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -243,7 +243,9 @@ Well, you found me. Congratulations. Was it worth it\? Close + No selection Save + Save as file Duplicate Edit Skip @@ -1029,4 +1031,7 @@ departed 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 + Name + Author \ No newline at end of file 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 new file mode 100644 index 00000000..319f675b --- /dev/null +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/ThemeBundle.kt @@ -0,0 +1,69 @@ +package de.mm20.launcher2.themes + +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.serialization.Json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject + +@Serializable +data class ThemeBundle( + val name: String, + val author: String? = null, + val colors: Colors? = null, + val shapes: Shapes? = null, + /** + * The file version, always 2 for the new theme format. + */ + val version: Int = 2, +) { + fun toJson(): String { + return Json.Lenient.encodeToString(this) + } + + companion object { + fun fromJson(jsonString: String): ThemeBundle? { + try { + val jsonElement = Json.Lenient.parseToJsonElement(jsonString).jsonObject + + val version = (jsonElement.get("version") as? JsonPrimitive)?.intOrNull + + if (version != 2) { + return fromLegacyJson(jsonElement) + } + + return Json.Lenient.decodeFromJsonElement(jsonElement) + + } catch (e: SerializationException) { + CrashReporter.logException(e) + return null + } catch (e: IllegalArgumentException) { + CrashReporter.logException(e) + return null + } + } + + private fun fromLegacyJson(jsonElement: JsonElement): ThemeBundle? { + try { + val colorScheme: Colors = LegacyThemeJson.decodeFromJsonElement(jsonElement) + return ThemeBundle( + name = colorScheme.name, + author = "", + colors = colorScheme, + shapes = null, + version = 2, + ) + } catch (e: SerializationException) { + CrashReporter.logException(e) + return null + } + + } + } +} \ No newline at end of file