New theme file format and exporter

(But no import yet)

Close #1443
This commit is contained in:
MM20
2025-06-04 23:56:00 +02:00
parent 28232a33b4
commit 5ac4022eb5
9 changed files with 377 additions and 27 deletions
@@ -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()
}
@@ -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)) {
@@ -0,0 +1,181 @@
package de.mm20.launcher2.ui.settings.appearance
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CropSquare
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SplitButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.TextPreference
@Composable
fun ExportThemeSettingsScreen() {
val viewModel = viewModel<ExportThemeSettingsScreenVM>()
val context = LocalContext.current
val colorSchemes by viewModel.colorSchemes.collectAsState(emptyList())
val shapeThemes by viewModel.shapeSchemes.collectAsState(emptyList())
val isValidSelection by remember {
derivedStateOf {
viewModel.colorScheme != null || viewModel.shapeScheme != null
}
}
val fileChooserLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.de.mm20.launcher2.theme")) {
if (it != null) viewModel.exportTheme(context, it)
}
LaunchedEffect(Unit) {
viewModel.init()
}
PreferenceScreen(
title = stringResource(R.string.theme_export_title)
) {
item {
PreferenceCategory {
TextPreference(
stringResource(R.string.theme_bundle_name),
value = viewModel.themeName,
summary = viewModel.themeName.takeIf { it.isNotBlank() },
onValueChanged = {
viewModel.themeName = it
}
)
TextPreference(
stringResource(R.string.theme_bundle_author),
value = viewModel.themeAuthor,
summary = viewModel.themeAuthor.takeIf { it.isNotBlank() },
onValueChanged = {
viewModel.themeAuthor = it
}
)
}
}
item {
PreferenceCategory {
ListPreference(
stringResource(R.string.preference_screen_colors),
icon = Icons.Rounded.Palette,
value = viewModel.colorScheme,
items = listOf(stringResource(R.string.no_selection) to null) + colorSchemes.map {
it.name to it
},
onValueChanged = { newValue ->
viewModel.setColorScheme(newValue)
}
)
ListPreference(
stringResource(R.string.preference_screen_shapes),
icon = Icons.Rounded.CropSquare,
value = viewModel.shapeScheme,
items = listOf(stringResource(R.string.no_selection) to null) + shapeThemes.map {
it.name to it
},
onValueChanged = { newValue ->
viewModel.setShapeScheme(newValue)
}
)
}
}
item {
PreferenceCategory {
var showDropdown by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(SplitButtonDefaults.Spacing)
) {
SplitButtonDefaults.LeadingButton(
modifier = Modifier.weight(1f),
enabled = isValidSelection,
onClick = {
viewModel.shareTheme(context)
}
) {
Icon(
Icons.Rounded.Share,
null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(SplitButtonDefaults.LeadingIconSize),
)
Text(stringResource(R.string.menu_share))
}
SplitButtonDefaults.TrailingButton(
onClick = {
showDropdown = !showDropdown
},
enabled = isValidSelection,
) {
val rotation: Float by animateFloatAsState(
targetValue = if (showDropdown) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
Icons.Rounded.KeyboardArrowDown,
modifier =
Modifier
.size(SplitButtonDefaults.TrailingIconSize)
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = null
)
DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.save_as_file)) },
onClick = {
fileChooserLauncher.launch("${viewModel.themeName}.kvtheme")
showDropdown = false
},
leadingIcon = { Icon(Icons.Rounded.Save, contentDescription = null) }
)
}
}
}
}
}
}
}
@@ -0,0 +1,95 @@
package de.mm20.launcher2.ui.settings.appearance
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.themes.Colors
import de.mm20.launcher2.themes.Shapes
import de.mm20.launcher2.themes.ThemeBundle
import de.mm20.launcher2.themes.ThemeRepository
import de.mm20.launcher2.themes.toLegacyJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class ExportThemeSettingsScreenVM: ViewModel(), KoinComponent {
private val themeRepository: ThemeRepository by inject()
val colorSchemes = themeRepository.getAllColors().map { it.filter { !it.builtIn } }
val shapeSchemes = themeRepository.getAllShapes().map { it.filter { !it.builtIn } }
var themeName by mutableStateOf("")
var themeAuthor by mutableStateOf("")
fun init() {
themeName = ""
}
var colorScheme by mutableStateOf<Colors?>(null)
@JvmName("_setColorScheme")
private set
fun setColorScheme(scheme: Colors?) {
if (themeName.isBlank() && scheme != null) themeName = scheme.name
colorScheme = scheme
}
var shapeScheme by mutableStateOf<Shapes?>(null)
@JvmName("_setShapeScheme")
private set
fun setShapeScheme(scheme: Shapes?) {
if (themeName.isBlank() && scheme != null) themeName = scheme.name
shapeScheme = scheme
}
private fun getThemeBundle(): ThemeBundle {
return ThemeBundle(
name = themeName,
author = themeAuthor.takeIf { it.isNotBlank() },
colors = colorScheme,
shapes = shapeScheme,
)
}
fun exportTheme(context: Context, uri: Uri) {
val themeBundle = getThemeBundle()
viewModelScope.launch(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.writer()?.use {
it.write(themeBundle.toJson())
}
}
}
fun shareTheme(context: Context) {
val themeBundle = getThemeBundle()
viewModelScope.launch {
val file = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "${themeName}.kvtheme")
file.writeText(themeBundle.toJson())
file
}
context.tryStartActivity(Intent().apply {
action = Intent.ACTION_SEND
type = "application/json"
putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
file
))
}.let { Intent.createChooser(it, null) })
}
}
}
@@ -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()
@@ -67,24 +67,13 @@ fun ColorSchemesSettingsScreen() {
var deleteColors by remember { mutableStateOf<Colors?>(null) }
var importThemeUri by remember { mutableStateOf<Uri?>(null) }
val importIntentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
importThemeUri = it
}
PreferenceScreen(
title = stringResource(R.string.preference_screen_colors),
topBarActions = {
IconButton(onClick = { importIntentLauncher.launch(arrayOf("*/*")) }) {
Icon(Icons.Rounded.Download, null)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.createNew(context) }) {
IconButton(onClick = { viewModel.createNew(context) }) {
Icon(Icons.Rounded.Add, null)
}
}
},
) {
item {
PreferenceCategory {
@@ -192,10 +181,6 @@ fun ColorSchemesSettingsScreen() {
}
)
}
if (importThemeUri != null) {
ImportThemeSheet(uri = importThemeUri!!, onDismiss = { importThemeUri = null })
}
}
@Composable
@@ -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 {